Files
reasampler/src/core/model/bank_book.h
T
daniel a927dad2f4 import: a .rsbank lands as a new bank, whole or not at all
Four collisions answered explicitly: ids reminted, names never overwritten,
content deduped before the write, bank name auto-suffixed. Degraded ledger
refuses before the picker.
2026-08-02 17:19:30 -04:00

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