#include "core/model/bank_book.h" #include #include // bank_book implementation — the registry RULES half: construction, pool // privileges, bank lifecycle, active bank, sample movement/removal, slot order, // and the reference queries. The JSON round-trip half lives in bank_book_json.cpp, // compiled into the same target. The one symbol both halves share is the private // static BankBook::nameKey display-name folding rule (declared in bank_book.h). namespace reasampler { // -- construction + bank lookup ---------------------------------------------- BankBook::BankBook() { Bank pool; pool.id = kPoolBankId; pool.displayName = kPoolBankName; pool.ordinal = 0; banks_.push_back(std::move(pool)); activeBankId_ = kPoolBankId; } Bank* BankBook::bank(const std::string& id) { for (auto& b : banks_) if (b.id == id) return &b; return nullptr; } const Bank* BankBook::bank(const std::string& id) const { for (const auto& b : banks_) if (b.id == id) return &b; return nullptr; } BankModel* BankBook::index(const std::string& id) { Bank* b = bank(id); return b ? &b->index : nullptr; } const BankModel* BankBook::index(const std::string& id) const { const Bank* b = bank(id); return b ? &b->index : nullptr; } Bank& BankBook::pool() { // The pool is seeded on construction and is un-deletable, so it always exists. return *bank(kPoolBankId); } const Bank& BankBook::pool() const { return *bank(kPoolBankId); } // -- Ordinal normalization ---------------------------------------------------- void BankBook::normalizeOrdinals() { // Stable-sort by ordinal with the pool pinned first, then rewrite ordinals to a // contiguous 0..N-1. Stability preserves the caller's relative order among banks // that share (or, after a reorder shuffle, tie on) an ordinal. std::stable_sort(banks_.begin(), banks_.end(), [](const Bank& a, const Bank& b) { if (a.isPool() != b.isPool()) return a.isPool(); // pool always first return a.ordinal < b.ordinal; }); for (std::size_t i = 0; i < banks_.size(); ++i) banks_[i].ordinal = static_cast(i); } // -- Display-name uniqueness (trimmed + case-insensitive, ASCII) -------------- // 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; bank names // are short user labels, not Unicode case-folding candidates. Private static: the // one folding rule shared with bank_book_json.cpp's parse-time coalesce. std::string BankBook::nameKey(const std::string& s) { std::size_t b = 0, e = s.size(); auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }; while (b < e && isWs(s[b])) ++b; while (e > b && isWs(s[e - 1])) --e; std::string out; out.reserve(e - b); for (std::size_t i = b; i < e; ++i) { char c = s[i]; if (c >= 'A' && c <= 'Z') c = static_cast(c - 'A' + 'a'); out += c; } return out; } // True if any bank OTHER than `exceptId` already carries `name`'s uniqueness key. The // exception lets renameBank accept a bank keeping (or re-casing/-spacing) its own name. bool BankBook::displayNameTaken(const std::string& name, const std::string& exceptId) const { const std::string key = nameKey(name); for (const auto& b : banks_) if (b.id != exceptId && nameKey(b.displayName) == key) return true; return false; } // -- Bank lifecycle ------------------------------------------------------------ bool BankBook::createBank(const std::string& id, const std::string& displayName) { if (id.empty()) return false; // ids key the registry if (id == kPoolBankId) return false; // reserved pool id if (bank(id) != nullptr) return false; // duplicate id // Display names are unique (trimmed + case-insensitive); the pool's "Pool" is a // reserved name and is caught here like any other collision. if (displayNameTaken(displayName, /*exceptId=*/id)) return false; Bank b; b.id = id; b.displayName = displayName; b.ordinal = static_cast(banks_.size()); // append; normalize compacts it banks_.push_back(std::move(b)); normalizeOrdinals(); return true; } // Runs behind displayNameTaken so the probe and the create/rename check can never // disagree about what "already used" means. exceptId is deliberately "" — no bank can // carry an empty id, so nothing is excluded from the scan. std::string BankBook::uniqueDisplayName(const std::string& seed) const { if (!displayNameTaken(seed, /*exceptId=*/std::string{})) return seed; for (int n = 2;; ++n) { std::string candidate = seed + " " + std::to_string(n); if (!displayNameTaken(candidate, /*exceptId=*/std::string{})) return candidate; } } bool BankBook::renameBank(const std::string& id, const std::string& displayName) { if (id == kPoolBankId) return false; // pool is un-renamable Bank* b = bank(id); if (b == nullptr) return false; // Reject a name already used by a DIFFERENT bank. Renaming a bank to its own // current name (or a case/space variant of it) is a no-op success, not a // rejection — exceptId=id excludes the bank itself from the collision scan. if (displayNameTaken(displayName, /*exceptId=*/id)) return false; b->displayName = displayName; return true; } bool BankBook::deleteBank(const std::string& id) { if (id == kPoolBankId) return false; // pool is un-deletable auto it = std::find_if(banks_.begin(), banks_.end(), [&](const Bank& b) { return b.id == id; }); if (it == banks_.end()) return false; banks_.erase(it); // If the active bank was the one deleted, fall back to the pool (invariant: the // active id always names a live bank). if (activeBankId_ == id) activeBankId_ = kPoolBankId; normalizeOrdinals(); return true; } bool BankBook::reorderBank(const std::string& id, int newOrdinal) { if (id == kPoolBankId) return false; // pool is pinned at ordinal 0 if (bank(id) == nullptr) return false; // Work on the named banks as an ordered list (banks_ is already ordinal-sorted // with the pool first, so named banks are banks_[1..]). Pull the target out and // re-insert it at the requested position, clamped into the named-bank range // [1..N], then rewrite ordinals contiguously. This is O(N) and obviously correct. std::vector named; named.reserve(banks_.size()); for (auto& b : banks_) if (!b.isPool()) named.push_back(std::move(b)); auto it = std::find_if(named.begin(), named.end(), [&](const Bank& b) { return b.id == id; }); Bank moved = std::move(*it); named.erase(it); // Named ordinals are 1..N; convert to a 0-based insertion index into `named`. const int hi = static_cast(named.size()); // insert-at range is [0..size] int insertAt = std::max(0, std::min(newOrdinal - 1, hi)); named.insert(named.begin() + insertAt, std::move(moved)); // Rebuild banks_: pool first, then the reordered named banks. Assign ordinals // directly by position here — NOT via normalizeOrdinals(), whose stable_sort keys // on the (now stale) ordinals and would undo the reinsertion order. std::vector rebuilt; rebuilt.reserve(named.size() + 1); rebuilt.push_back(std::move(pool())); for (auto& b : named) rebuilt.push_back(std::move(b)); banks_ = std::move(rebuilt); for (std::size_t i = 0; i < banks_.size(); ++i) banks_[i].ordinal = static_cast(i); return true; } bool BankBook::evacuate(const std::string& id) { if (id == kPoolBankId) return false; // pool is un-evacuable (it is the target) Bank* src = bank(id); if (src == nullptr) return false; // Move every member into the pool, index-only, observing destination collapse. // Snapshot the members first, then clear the source — BankModel has no bulk move, // and adding into the pool must not alias the vector we are draining. BankModel& poolIndex = pool().index; const std::vector members = src->index.all(); // copy for (const auto& s : members) poolIndex.add(s); // Added or Collapsed; either way the pool now holds the hash src->index = BankModel{}; // leave the evacuated bank empty return true; } // -- Active bank ---------------------------------------------------------------- bool BankBook::setActiveBank(const std::string& id) { if (bank(id) == nullptr) return false; // unknown id never corrupts state activeBankId_ = id; return true; } BankModel& BankBook::activeIndex() { // activeBankId_ always names a live bank; it falls back to the pool on delete. return bank(activeBankId_)->index; } const BankModel& BankBook::activeIndex() const { return bank(activeBankId_)->index; } // -- Sample movement (index-only) ----------------------------------------------- namespace { // Adds `s` to `dest` and maps the BankModel outcome onto the transfer outcome for // the "gained a NEW entry" case (`gained`) vs the collapse case. Rejected outcomes // (absolute path / empty id) cannot occur here: the sample already passed add() on // the source side, so its path and id are already valid. TransferResult applyDestAdd(BankModel& dest, const Sample& s, TransferResult gained) { return dest.add(s) == AddResult::Collapsed ? TransferResult::Collapsed : gained; } } // namespace TransferResult BankBook::moveSample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId) { Bank* from = bank(fromBankId); Bank* to = bank(toBankId); if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; if (fromBankId == toBankId) return TransferResult::RejectedSameBank; const Sample* s = from->index.query(sampleId); if (s == nullptr) return TransferResult::RejectedSampleAbsent; // Copy the sample out before removing it: query returns a pointer into the // source vector that remove() invalidates. const Sample moved = *s; from->index.remove(sampleId); // source loses the entry unconditionally on a move return applyDestAdd(to->index, moved, TransferResult::Moved); } TransferResult BankBook::copySample(const std::string& sampleId, const std::string& fromBankId, const std::string& toBankId) { Bank* from = bank(fromBankId); Bank* to = bank(toBankId); if (from == nullptr || to == nullptr) return TransferResult::RejectedUnknownBank; if (fromBankId == toBankId) return TransferResult::RejectedSameBank; const Sample* s = from->index.query(sampleId); if (s == nullptr) return TransferResult::RejectedSampleAbsent; const Sample copy = *s; // source entry is left intact return applyDestAdd(to->index, copy, TransferResult::Copied); } // -- Sample removal (index-only) + the last-reference query --------------------- RemoveResult BankBook::removeSample(const std::string& sampleId, const std::string& fromBankId, RemoveScope scope) { if (scope == RemoveScope::AllBanks) { // Latent seam: purge the id from every bank that holds it. fromBankId is // ignored (the id is dropped book-wide). Removed iff at least one drop landed. bool any = false; for (auto& b : banks_) if (b.index.remove(sampleId)) { b.slots.remove(sampleId); // keep SlotMap in sync: leave an empty gap any = true; } return any ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent; } // ThisBank (default, the only surfaced verb): drop from the one named source bank. Bank* from = bank(fromBankId); if (from == nullptr) return RemoveResult::RejectedUnknownBank; if (!from->index.remove(sampleId)) return RemoveResult::RejectedSampleAbsent; from->slots.remove(sampleId); // keep SlotMap in sync: the removed sample's slot becomes a gap return RemoveResult::Removed; } bool BankBook::updateSampleInPlace(const std::string& sampleId, const Sample& updated) { for (auto& b : banks_) if (b.index.query(sampleId) != nullptr) return b.index.updateInPlace(sampleId, updated); return false; // no bank holds the id } // -- Sample display order — SlotMap driven, index membership untouched ----------- namespace { // The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed. std::vector indexIds(const BankModel& idx) { std::vector ids; for (const auto& s : idx.all()) ids.push_back(s.id); return ids; } // Squares one bank's SlotMap with its index membership. A map with NO overlap with // the index (a bank with no persisted slot data, or freshly constructed) is seeded // dense from insertion order; an existing map is reconciled (drop stale, append unmapped). void reconcileBankSlots(Bank& b) { const std::vector live = indexIds(b.index); if (b.slots.empty()) { b.slots.resetDense(live); // migration / first-population default: dense, no gaps return; } b.slots.reconcile(live); // partial map: keep positions, drop stale, append new } } // namespace void BankBook::reconcileSlots() { for (auto& b : banks_) reconcileBankSlots(b); } std::vector BankBook::orderedSampleIds(const std::string& bankId) { Bank* b = bank(bankId); if (b == nullptr) return {}; reconcileBankSlots(*b); // ensure the map covers all live members return b->slots.orderedIds(); } bool BankBook::reorderSample(const std::string& id, const std::string& bankId, int targetSlot) { Bank* b = bank(bankId); if (b == nullptr) return false; if (b->index.query(id) == nullptr) return false; // bank does not hold the sample reconcileBankSlots(*b); // complete the target space first return b->slots.reorder(id, targetSlot); // gap-preserving; index untouched } bool BankBook::replaceSample(const std::string& newId, const std::string& oldId, const std::string& bankId) { if (newId == oldId) return false; Bank* b = bank(bankId); if (b == nullptr) return false; // Both the dragged sample and the occupant must live in this bank. if (b->index.query(newId) == nullptr) return false; if (b->index.query(oldId) == nullptr) return false; reconcileBankSlots(*b); // complete the map so oldId's slot is known // Capture the target slot BEFORE any mutation so the position survives the removal. const int targetSlot = b->slots.slotOf(oldId); if (targetSlot < 0) return false; // occupant not positioned (shouldn't happen post-reconcile) // POOL GUARD (settled): the occupant's index-removal must pass the SAME guard the // remove verb applies. Commit the removal FIRST so a rejection is a true no-op (no // slot mutation happened yet). removeSample(ThisBank) permits per-sample removal from // any bank incl. the pool (per-sample remove is not a pool privilege), so it succeeds // whenever the occupant exists — which we verified — but routing through it means a // future pool-floor guard added to remove governs replace identically, one rule. const RemoveResult r = removeSample(oldId, bankId, RemoveScope::ThisBank); if (r != RemoveResult::Removed) return false; // guard rejected -> nothing changed // Occupant gone from the index; now update the slot markers. Drop oldId's now-dangling // marker to free the target slot, then move newId onto it. reorder onto an EMPTY slot // places newId there exactly and empties newId's own (source) slot — the slot position // is preserved and only its occupant changed, exactly the replace contract. b->slots.remove(oldId); b->slots.reorder(newId, targetSlot); return true; } bool BankBook::hashReferencedElsewhere(const std::string& hash, const std::string& exceptBankId) const { if (hash.empty()) return false; // empty hashes never dedup (mirror findByHash) for (const auto& b : banks_) { if (b.id == exceptBankId) continue; // the removed-from bank is excluded if (b.index.findByHash(hash) != nullptr) return true; } return false; } std::vector BankBook::referencedPaths() const { // Union across the whole book (pool first, then named banks in ordinal order — // banks_ is kept ordinal-sorted). De-duplicate by exact string so a file a copy // put in two banks appears once. Skip empty paths (they reference no file). std::vector paths; std::unordered_set seen; for (const auto& b : banks_) { for (const auto& s : b.index.all()) { if (s.relativePath.empty()) continue; if (seen.insert(s.relativePath).second) paths.push_back(s.relativePath); } } return paths; } // =========================================================================== // JSON — serialize / deserialize / adoptBanks live in bank_book_json.cpp // (Q-W5 extraction; byte-identical format, golden-literal-pinned by the Q-W1 // test). loadFromPersisted stays here: it is the load-source PRECEDENCE rule // (banks-blob vs legacy vs empty), not the codec. // =========================================================================== // --------------------------------------------------------------------------- // Active-bank cycle ordering (pure, free function — mirror of nextModeId) // --------------------------------------------------------------------------- std::string nextBankId(const std::vector& orderedBankIds, const std::string& currentBankId) { if (orderedBankIds.empty()) return {}; // nothing to cycle to for (std::size_t i = 0; i < orderedBankIds.size(); ++i) { if (orderedBankIds[i] == currentBankId) return orderedBankIds[(i + 1) % orderedBankIds.size()]; // wrap past the last } // Active id not in the list (stale/unknown) — jump to the first id as a sane // home rather than returning "" (matches nextModeId's fallback). return orderedBankIds.front(); } BankBook BankBook::loadFromPersisted(const std::string& banksJson, const std::string& legacyJson) { // Precedence 1: the authoritative `banks` blob. A present-but-malformed blob is // an error, not an absence — degrade to an empty book rather than falling through // to a stale legacy key (which would resurrect superseded single-bank state). if (!banksJson.empty()) { auto book = deserialize(banksJson); return book ? std::move(*book) : BankBook{}; } // Precedence 2: no `banks` yet, but a legacy `bank_index` — one-way pool migration // (deserialize's parse-time legacy path promotes it into the pool). A malformed // legacy blob likewise degrades to empty. if (!legacyJson.empty()) { auto book = deserialize(legacyJson); return book ? std::move(*book) : BankBook{}; } // Precedence 3: a brand-new / never-captured project — a fresh empty book. return BankBook{}; } } // namespace reasampler