a927dad2f4
Four collisions answered explicitly: ids reminted, names never overwritten, content deduped before the write, bank name auto-suffixed. Degraded ledger refuses before the picker.
296 lines
15 KiB
C++
296 lines
15 KiB
C++
#pragma once
|
|
// bank_book — pure multi-bank registry: wraps N BankModel instances (bank_model
|
|
// itself is untouched — additive, no bankId on Sample). Movement between banks is
|
|
// index-only; files never relocate, banks are logical groupings over one shared pool.
|
|
//
|
|
// The pool is bank-zero (fixed id/name, ordinal 0), privileged and enforced HERE:
|
|
// always exists, un-deletable, un-renamable, un-evacuable (evacuate's destination
|
|
// only). createBank takes a caller-supplied id — REAPER GUID minting stays in the
|
|
// shell so this model stays pure and deterministic; the model still enforces
|
|
// non-empty/unique/not-reserved.
|
|
|
|
#include <optional>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "core/model/bank_model.h"
|
|
#include "core/model/slot_map.h"
|
|
|
|
namespace reasampler {
|
|
|
|
// Interim: this module re-namespaces later; the model types it wraps live in
|
|
// reasampler::model.
|
|
using namespace model;
|
|
|
|
// The pool's fixed identity: createBank rejects this id; renameBank rejects this name.
|
|
inline constexpr const char* kPoolBankId = "pool";
|
|
inline constexpr const char* kPoolBankName = "Pool";
|
|
|
|
// One bank: id/display/ordinal/BankModel/SlotMap. The pool is the bank whose
|
|
// id == kPoolBankId.
|
|
struct Bank {
|
|
std::string id; // stable, persisted; the pool's is kPoolBankId
|
|
std::string displayName; // mutable for named banks; fixed "Pool" for the pool
|
|
int ordinal = 0; // display order; pool is 0, named banks 1..N
|
|
BankModel index; // this bank's samples
|
|
SlotMap slots; // display positions of this bank's samples (gap-preserving)
|
|
|
|
bool isPool() const { return id == kPoolBankId; }
|
|
|
|
bool operator==(const Bank& o) const {
|
|
return id == o.id && displayName == o.displayName &&
|
|
ordinal == o.ordinal && index == o.index && slots == o.slots;
|
|
}
|
|
};
|
|
|
|
// Outcome of a cross-bank move/copy — reports what happened rather than mutating
|
|
// silently on a bad request.
|
|
// - Moved / Copied: transferred to the destination as a new entry.
|
|
// - Collapsed: destination already held the hash, collapsed onto it (move
|
|
// still removes the source; copy keeps it, as always).
|
|
// - RejectedUnknownBank / RejectedSampleAbsent / RejectedSameBank: no-op guards.
|
|
enum class TransferResult {
|
|
Moved,
|
|
Copied,
|
|
Collapsed,
|
|
RejectedUnknownBank,
|
|
RejectedSampleAbsent,
|
|
RejectedSameBank,
|
|
};
|
|
|
|
// Scope of a sample-remove. ThisBank is the only behavior surfaced in the UI;
|
|
// AllBanks is a tested latent seam, not wired to any affordance.
|
|
// - ThisBank: drop the entry from the one named source bank only (no cross-bank
|
|
// cascade — dedup is per-bank).
|
|
// - AllBanks: drop the sample's entry from every bank holding it ("purge from
|
|
// the library").
|
|
enum class RemoveScope {
|
|
ThisBank,
|
|
AllBanks,
|
|
};
|
|
|
|
// Outcome of BankBook::removeSample — same honesty as TransferResult.
|
|
// - Removed: at least one index entry was dropped.
|
|
// - RejectedUnknownBank: the source bank id named no bank (ThisBank scope only).
|
|
// - RejectedSampleAbsent: the sample id was in no bank in scope (nothing removed).
|
|
enum class RemoveResult {
|
|
Removed,
|
|
RejectedUnknownBank,
|
|
RejectedSampleAbsent,
|
|
};
|
|
|
|
// An ordered registry of banks with the pool seeded as bank-zero, per-bank sample
|
|
// indices, an active-bank pointer, and lossless JSON round-trip.
|
|
class BankBook {
|
|
public:
|
|
BankBook(); // seeds the pool (id kPoolBankId, name kPoolBankName, ordinal 0);
|
|
// active bank = pool; zero named banks.
|
|
|
|
// -- Bank lifecycle ------------------------------------------------------
|
|
|
|
// Creates a named bank with a caller-supplied id/display name (next ordinal
|
|
// assigned automatically). Rejects (false, no mutation) an empty/duplicate id,
|
|
// the reserved pool id, or a duplicate display name (trimmed + case-insensitive,
|
|
// ASCII — "Drums"/"drums"/" Drums " collide, including against the pool's "Pool").
|
|
bool createBank(const std::string& id, const std::string& displayName);
|
|
|
|
// Renames a named bank. Rejects (false, no mutation) an unknown id, the pool, or
|
|
// a name already used by another bank. Renaming to its own current name is a
|
|
// no-op success.
|
|
bool renameBank(const std::string& id, const std::string& displayName);
|
|
|
|
// The first name in the sequence `seed`, "seed 2", "seed 3", … whose fold is free
|
|
// in this book — what a caller that must not be rejected (the package import) asks
|
|
// for before createBank. First-FREE-ascending, not highest-plus-one, so it fills a
|
|
// gap ("Drums" + "Drums 3" present yields "Drums 2") and is a pure function of the
|
|
// current name set. The seed is returned verbatim when free and is NEVER re-parsed:
|
|
// a bare trailing integer cannot be told from a user's own name, so "Kit 808" would
|
|
// become "Kit 2" under a stripping rule. Terminates by pigeonhole (one of the first
|
|
// N+1 candidates is free for N banks), so it needs no cap. A blank seed comes back
|
|
// blank — what a missing name should become is the caller's policy, not the model's.
|
|
std::string uniqueDisplayName(const std::string& seed) const;
|
|
|
|
// Deletes a named bank and its member entries (files untouched — a shell/prune
|
|
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
|
|
// ordinals compact after; if the deleted bank was active, falls back to the pool.
|
|
bool deleteBank(const std::string& id);
|
|
|
|
// Reorders a named bank to newOrdinal (clamped into range, others shift to stay
|
|
// contiguous). The pool is pinned at 0. Rejects an unknown id or the pool.
|
|
bool reorderBank(const std::string& id, int newOrdinal);
|
|
|
|
// Moves every member of a named bank into the pool (index-only, same
|
|
// destination-collapse-by-hash as a move). Rejects an unknown id or the pool
|
|
// (the pool is only ever a destination). Returns true even if already empty.
|
|
bool evacuate(const std::string& id);
|
|
|
|
// -- Active bank ---------------------------------------------------------
|
|
|
|
// The active bank's id (the capture target). Defaults to the pool.
|
|
const std::string& activeBankId() const { return activeBankId_; }
|
|
|
|
// Sets the active bank. Rejects (false, no change) an id that names no bank.
|
|
bool setActiveBank(const std::string& id);
|
|
|
|
// The active bank's BankModel — always valid (falls back to the pool).
|
|
BankModel& activeIndex();
|
|
const BankModel& activeIndex() const;
|
|
|
|
// -- Sample movement (index-only; files never relocate) ------------------
|
|
|
|
// Moves a sample by id between banks (destination collapse-by-hash observed).
|
|
// See TransferResult for the full outcome set.
|
|
TransferResult moveSample(const std::string& sampleId,
|
|
const std::string& fromBankId,
|
|
const std::string& toBankId);
|
|
|
|
// Copies a sample by id between banks, source retained (destination
|
|
// collapse-by-hash observed). Cross-bank dedup is NOT enforced — the same hash
|
|
// may then live in both banks.
|
|
TransferResult copySample(const std::string& sampleId,
|
|
const std::string& fromBankId,
|
|
const std::string& toBankId);
|
|
|
|
// -- Sample removal (index-only; the file is NEVER touched — orphaned until prune) --
|
|
|
|
// Drops a sample's index entry — non-destructive to the file (a last-reference
|
|
// remove leaves it on disk, orphaned until prune reclaims it). See RemoveScope/
|
|
// RemoveResult for scope and outcome. No mutation on any Rejected outcome.
|
|
RemoveResult removeSample(const std::string& sampleId,
|
|
const std::string& fromBankId,
|
|
RemoveScope scope = RemoveScope::ThisBank);
|
|
|
|
// -- Sample display order (index membership untouched) -------------------
|
|
|
|
// The bank's sample ids in display (slot) order, reconciled against live index
|
|
// membership first (drops stale markers, appends unmapped samples densely). An
|
|
// unknown bank id yields an empty vector.
|
|
std::vector<std::string> orderedSampleIds(const std::string& bankId);
|
|
|
|
// Ensures every bank's SlotMap is consistent with its index membership (seeds a
|
|
// dense order, or reconciles a partial map). Idempotent — call after deserialize
|
|
// or any out-of-band membership change.
|
|
void reconcileSlots();
|
|
|
|
// Reorders sample `id` within `bankId` to targetSlot (gap-preserving; index/file/
|
|
// metadata untouched). Returns false (no mutation) on an unknown bank or id.
|
|
bool reorderSample(const std::string& id, const std::string& bankId, int targetSlot);
|
|
|
|
// Alt-replace: the dragged `newId` (already a member of bankId) takes the slot of
|
|
// `oldId`, and `oldId` is removed from the index (same semantics as removeSample
|
|
// ThisBank). Position is preserved; only the occupant changes.
|
|
//
|
|
// Applies the same pool guard as removeSample — per-sample removal from the pool
|
|
// is allowed (the pool's guards are un-delete/rename/evacuate, never per-sample
|
|
// remove). Rejects (false, no mutation of either index or slots) an unknown bank,
|
|
// a newId/oldId the bank doesn't hold, or newId == oldId.
|
|
bool replaceSample(const std::string& newId, const std::string& oldId,
|
|
const std::string& bankId);
|
|
|
|
// Refreshes a sample in place wherever it lives (re-capture): finds the bank
|
|
// holding sampleId and replaces its entry with `updated` (order-preserving, no
|
|
// dedup). Updates the FIRST holder in ordinal order if the id lives in multiple
|
|
// banks via copy. Returns false (no mutation) if no bank holds the id or the
|
|
// replacement's path is absolute.
|
|
bool updateSampleInPlace(const std::string& sampleId, const Sample& updated);
|
|
|
|
// Does any bank other than exceptBankId still hold an entry whose contentHash
|
|
// == hash? Backs the confirm-on-last-reference guardrail: two entries sharing a
|
|
// hash share one file, so this answers "would removing here orphan the file."
|
|
// An empty hash never matches (mirrors findByHash) — reads as
|
|
// referenced-nowhere-else, the safe confirm-eliciting default.
|
|
bool hashReferencedElsewhere(const std::string& hash,
|
|
const std::string& exceptBankId) const;
|
|
|
|
// Every project-relative path referenced by any bank (pool included) — the union
|
|
// prune subtracts against. Paths are verbatim (no normalization), first-seen
|
|
// order across banks in ordinal then insertion order, de-duplicated. An empty
|
|
// relativePath is skipped.
|
|
std::vector<std::string> referencedPaths() const;
|
|
|
|
// -- Query ---------------------------------------------------------------
|
|
|
|
// The bank with `id`, or nullptr. Pointer invalidated by any mutating call.
|
|
Bank* bank(const std::string& id);
|
|
const Bank* bank(const std::string& id) const;
|
|
|
|
// The bank's BankModel by id, or nullptr. Convenience over bank()->index.
|
|
BankModel* index(const std::string& id);
|
|
const BankModel* index(const std::string& id) const;
|
|
|
|
// The pool (always present). Never null.
|
|
Bank& pool();
|
|
const Bank& pool() const;
|
|
|
|
// All banks in ordinal order (pool first). The pool is always banks()[0].
|
|
const std::vector<Bank>& banks() const { return banks_; }
|
|
|
|
std::size_t size() const { return banks_.size(); } // >= 1 (the pool)
|
|
|
|
bool operator==(const BankBook& o) const {
|
|
return banks_ == o.banks_ && activeBankId_ == o.activeBankId_;
|
|
}
|
|
|
|
// -- Persistence ---------------------------------------------------------
|
|
|
|
// Serializes the whole book to JSON (lossless): pool as bank-zero + named banks
|
|
// + per-bank indices + ordinals + active id. deserialize(serialize(x)) == x.
|
|
std::string serialize() const;
|
|
|
|
// Parses a book JSON produced by serialize(). std::nullopt on malformed input.
|
|
//
|
|
// A bare legacy bank_index JSON (pre-multi-bank shape: a "samples" array, no
|
|
// "banks" key) is promoted into the pool's index — one-way, lossless — yielding
|
|
// a book of { pool } with zero named banks.
|
|
static std::optional<BankBook> deserialize(const std::string& json);
|
|
|
|
// Resolves a BankBook from the two persisted ext-state values a project may
|
|
// carry: the authoritative `banksJson` and the retired legacy `bank_index` blob.
|
|
// 1. non-empty banksJson -> deserialize it. If malformed, do NOT fall back to
|
|
// legacy — a corrupt banks blob is an error, not an absence; returns an
|
|
// empty book so a stale legacy key can never resurrect superseded state.
|
|
// 2. else non-empty legacyJson -> deserialize it (pool migration).
|
|
// 3. else -> a fresh empty book (pool only).
|
|
// Never returns nullopt — an unloadable input degrades to the empty book.
|
|
static BankBook loadFromPersisted(const std::string& banksJson,
|
|
const std::string& legacyJson);
|
|
|
|
private:
|
|
std::vector<Bank> banks_; // ordinal order; banks_[0] is always the pool
|
|
std::string activeBankId_; // always names a live bank; defaults to pool
|
|
|
|
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
|
|
// whitespace, lower-case ASCII letters — so "Drums"/"drums"/" Drums " share one
|
|
// key. ASCII-only by design — the pure core carries no locale facility. Private
|
|
// static because both halves of the split implementation (rules + JSON) need the
|
|
// one folding rule; a drifted second copy would let a parsed book violate the
|
|
// create/rename uniqueness invariant.
|
|
static std::string nameKey(const std::string& s);
|
|
|
|
// True if a bank other than exceptId already carries name's uniqueness key.
|
|
// Backs the create/rename uniqueness check; pass exceptId=id to let a bank keep
|
|
// (or re-case/-space) its own name.
|
|
bool displayNameTaken(const std::string& name, const std::string& exceptId) const;
|
|
|
|
// Re-sorts banks_ by ordinal (pool pinned first) and rewrites ordinals to a
|
|
// contiguous 0..N-1. Called after any structural change (create/delete/reorder).
|
|
void normalizeOrdinals();
|
|
|
|
// Replaces the book's banks with a parsed set, normalizes ordinals, and resolves
|
|
// the active bank (falling back to the pool if the id names no bank). Used only
|
|
// by deserialize.
|
|
void adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank);
|
|
};
|
|
|
|
// The next bank id to activate when cycling the active bank forward, in ordinal
|
|
// order (pool -> named -> ... -> pool, wraps). Free function (not a member) so it's
|
|
// unit-testable against a bare id vector without a full book.
|
|
// * empty list -> "" (nothing to cycle to)
|
|
// * single id (pool-only) -> that id (a one-bank book stays put)
|
|
// * currentBankId not present -> the first id (a sane home to jump to)
|
|
std::string nextBankId(const std::vector<std::string>& orderedBankIds,
|
|
const std::string& currentBankId);
|
|
|
|
} // namespace reasampler
|