Q-W1 pt2: core/shell/app relocation + sub-namespaces; one concrete ui::Rect (LTRB fork retired); slot_map split from bank_book; BankIndex→BankModel; 59/59 green

This commit is contained in:
2026-07-28 20:48:56 -04:00
parent 67a41728f3
commit 847936f813
222 changed files with 2247 additions and 2079 deletions
+737
View File
@@ -0,0 +1,737 @@
#include "core/model/bank_book.h"
#include <algorithm>
#include <unordered_set>
#include "core/json/json.h"
// bank_book implementation.
//
// JSON rides on the shared core/json lexical layer (Q-W1), matching bank_model
// and view_mode_model. The book blob nests one bank object per bank, each carrying that
// bank's BankModel serialized by bank_model's OWN writer (BankModel::serialize),
// so per-bank sample serialization stays owned by bank_model and is not duplicated
// here. The book writer emits the bank envelope (id / displayName / ordinal) plus a
// raw "index" member whose value is the BankModel blob verbatim; the parser splits
// the book envelope, then hands each nested index blob straight to
// BankModel::deserialize. Ints use %d; strings are escaped by writeEscaped.
namespace reasampler {
// SlotMap lives in core/model/slot_map.cpp (extracted Q-W1, T4-05).
// ---------------------------------------------------------------------------
// BankBook — 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<int>(i);
}
// ---------------------------------------------------------------------------
// Display-name uniqueness (trimmed + case-insensitive, ASCII)
// ---------------------------------------------------------------------------
namespace {
// Folds a display name to its uniqueness key: strip leading/trailing ASCII
// whitespace, lower-case ASCII letters. So "Drums", "drums", and " Drums " share one
// key and cannot coexist. ASCII-only by design — the pure core carries no locale
// facility and must not grow one; bank names are short user labels, not full Unicode
// case-folding candidates.
std::string 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<char>(c - 'A' + 'a');
out += c;
}
return out;
}
} // namespace
// 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<int>(banks_.size()); // append; normalize compacts it
banks_.push_back(std::move(b));
normalizeOrdinals();
return true;
}
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<Bank> 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<int>(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<Bank> 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<int>(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<Sample> 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 (L7) — SlotMap driven, index membership untouched
// ---------------------------------------------------------------------------
namespace {
// The bank's live sample ids in INDEX (insertion) order — the reconcile/migration seed.
std::vector<std::string> indexIds(const BankModel& idx) {
std::vector<std::string> 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 (the pre-L7 migration case, or a freshly-constructed bank) is seeded dense from
// insertion order; an existing map is reconciled (drop stale markers, append unmapped).
void reconcileBankSlots(Bank& b) {
const std::vector<std::string> 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<std::string> 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<std::string> 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<std::string> paths;
std::unordered_set<std::string> 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 — writer
// ===========================================================================
namespace {
// Shared core/json emit helpers (Q-W1): the same escape set + %d rendering the
// prior file-local writer carried, so the emitted blob is byte-identical.
std::string intToStr(int v) { return json::numToStr(v); }
using ObjWriter = json::Writer;
} // namespace
std::string BankBook::serialize() const {
std::string out;
{
ObjWriter root(out);
root.keyRaw("version", intToStr(1));
root.keyStr("activeBank", activeBankId_);
// banks: array of { id, displayName, ordinal, index: <BankModel blob> }.
// The pool rides in as bank-zero, persisted identically to any named bank.
root.keyBegin("banks");
out += '[';
for (std::size_t i = 0; i < banks_.size(); ++i) {
if (i) out += ',';
ObjWriter b(out);
b.keyStr("id", banks_[i].id);
b.keyStr("displayName", banks_[i].displayName);
b.keyRaw("ordinal", intToStr(banks_[i].ordinal));
// The nested index is bank_model's own JSON, emitted verbatim so the
// per-sample shape stays owned by BankModel::serialize (not duplicated).
b.keyRaw("index", banks_[i].index.serialize());
// L7 display positions (gap-preserving). Absent on a pre-L7 blob; the
// parser defaults such a bank's slots from insertion order on load.
b.keyRaw("slots", banks_[i].slots.serialize());
}
out += ']';
} // root closes here (see bank_model note on NRVO + deferred close)
return out;
}
// ===========================================================================
// JSON — parser (recursive descent; std::nullopt on any malformed input, never UB)
// ===========================================================================
namespace {
// The book DOMAIN grammar over the shared core/json lexical layer (Q-W1).
// parseBank parses one bank object; parseSlots the "slots" array ([{id, slot},
// ...]) into (id, slot) pairs (empty array valid; the pair-level defensive
// repair — dupes/conflicts — lives in SlotMap::fromEntries); parseBook the root
// blob, distinguishing the legacy shape (a bare bank_index object: has
// "samples", no "banks") from the book shape (has "banks"): a legacy blob
// yields a single pool bank carrying the migrated index and an empty active id
// (⇒ pool). The member deserialize() adopts the result (ordinal normalize +
// active resolve).
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out);
bool parseBank(json::Reader& r, Bank& b) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // a bank object must at least carry an id
bool haveId = false;
bool haveIndex = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "id") {
if (!r.parseString(b.id)) return false;
haveId = true;
} else if (key == "displayName") {
if (!r.parseString(b.displayName)) return false;
} else if (key == "ordinal") {
if (!r.parseInt(b.ordinal)) return false;
} else if (key == "index") {
std::string raw;
if (!r.captureValue(raw)) return false;
auto idx = BankModel::deserialize(raw);
if (!idx) return false; // a malformed nested index fails the whole parse
b.index = std::move(*idx);
haveIndex = true;
} else if (key == "slots") {
// L7 display positions. Absent on a pre-L7 blob (the else-branch skips
// nothing because the key never appears); when present it drives the
// bank's SlotMap. reconcileSlots() (post-adopt) squares it with membership.
std::vector<std::pair<std::string, int>> pairs;
if (!parseSlots(r, pairs)) return false;
b.slots = SlotMap::fromEntries(pairs);
} else {
if (!r.skipValue()) return false; // forward-compat unknown keys
}
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || b.id.empty()) return false; // id keys the registry
if (!haveIndex) return false; // every bank persists its index
return true;
}
bool parseSlots(json::Reader& r, std::vector<std::pair<std::string, int>>& out) {
out.clear();
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true; // empty slot array — a bank with no positions yet
do {
if (!r.consume('{')) return false;
std::string id;
int slot = 0;
bool haveId = false, haveSlot = false;
do {
std::string k;
if (!r.parseKey(k)) return false;
if (k == "id") { if (!r.parseString(id)) return false; haveId = true; }
else if (k == "slot") { if (!r.parseInt(slot)) return false; haveSlot = true; }
else { if (!r.skipValue()) return false; } // forward-compat
} while (r.consume(','));
if (!r.consume('}')) return false;
if (!haveId || !haveSlot) return false; // a slot entry needs both
out.emplace_back(std::move(id), slot);
} while (r.consume(','));
return r.consume(']');
}
bool parseBook(json::Reader& r, const std::string& raw, std::vector<Bank>& banks,
std::string& activeBank) {
banks.clear();
activeBank.clear();
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return false; // an empty object is neither shape → malformed
// Decide the shape by which structural key we saw. A "banks" key ⇒ book shape; a
// "samples" key with no "banks" ⇒ legacy shape (promote into the pool).
std::vector<Bank> parsedBanks;
bool sawBanks = false;
bool sawSamples = false;
do {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "banks") {
sawBanks = true;
if (!r.consume('[')) return false;
r.skipWs();
if (!r.consume(']')) {
do {
Bank b;
if (!parseBank(r, b)) return false;
parsedBanks.push_back(std::move(b));
} while (r.consume(','));
if (!r.consume(']')) return false;
}
} else if (key == "activeBank") {
if (!r.parseString(activeBank)) return false;
} else if (key == "samples") {
// Legacy marker. The legacy index is re-parsed from the whole input below
// (BankModel::deserialize owns that shape); here we only skip the value to
// keep the scan well-formed and note that we saw it.
sawSamples = true;
if (!r.skipValue()) return false;
} else {
if (!r.skipValue()) return false; // version, or unknown
}
} while (r.consume(','));
if (!r.consume('}')) return false;
r.skipWs();
if (!r.eof()) return false; // trailing garbage
// --- Legacy migration: a bare bank_index (samples, no banks) → pool. ---
if (!sawBanks) {
if (!sawSamples) return false; // neither shape's marker → malformed
auto legacy = BankModel::deserialize(raw);
if (!legacy) return false;
Bank pool;
pool.id = kPoolBankId;
pool.displayName = kPoolBankName;
pool.ordinal = 0;
pool.index = std::move(*legacy);
banks.push_back(std::move(pool)); // { pool } with zero named banks
activeBank.clear(); // ⇒ pool (default) after adoption
return true;
}
// --- Book shape: the parsed banks ARE the book (pool folded in). ---
// The pool must be present as bank-zero (serialize always emits it). Reject a
// book blob that omits it rather than silently re-seeding — a book without its
// pool is malformed, not a legacy blob.
bool hasPool = std::any_of(parsedBanks.begin(), parsedBanks.end(),
[](const Bank& b) { return b.isPool(); });
if (!hasPool) return false;
// Reject duplicate bank ids (ids key the registry; a dup would corrupt lookup).
for (std::size_t i = 0; i < parsedBanks.size(); ++i)
for (std::size_t j = i + 1; j < parsedBanks.size(); ++j)
if (parsedBanks[i].id == parsedBanks[j].id) return false;
// Force the pool's fixed display name — it is not user-mutable, so we do not
// trust a persisted override for it (keeps kPoolBankName authoritative).
for (auto& b : parsedBanks)
if (b.isPool()) b.displayName = kPoolBankName;
// --- Coalesce duplicate folded display names (B4 re-review fold-in). --------
// The in-model create/rename path enforces unique display names under nameKey,
// but a hand-edited .rpp blob can smuggle in two banks whose names fold to the
// same key ("Drums" and " drums "). Rejecting the whole book over one collision
// would degrade the user's entire library to empty, so instead we AUTO-
// DISAMBIGUATE the later duplicate deterministically: scan in parse order, and
// the first time a folded key repeats, suffix that bank's display name (" 2",
// " 3", …) until its folded key is unique among all names seen so far. The FIRST
// bank to carry a key keeps its name verbatim; only subsequent collisions are
// renamed. No bank or sample is lost, and ids are untouched. The pool is included
// in the seen-set (its "Pool" key is reserved) so a named bank folding to "pool"
// is disambiguated away from it, never the reverse.
{
std::vector<std::string> seenKeys;
seenKeys.reserve(parsedBanks.size());
for (auto& b : parsedBanks) {
if (b.isPool()) { // pool's name is fixed; reserve its key
seenKeys.push_back(nameKey(b.displayName));
continue;
}
const auto taken = [&](const std::string& k) {
return std::find(seenKeys.begin(), seenKeys.end(), k) != seenKeys.end();
};
std::string key = nameKey(b.displayName);
if (taken(key)) {
// Suffix with an ascending integer until the folded key is free. Guard
// against a pathological blob whose base name already ends in a number
// by folding the candidate each attempt (nameKey normalizes it).
const std::string base = b.displayName;
for (int n = 2;; ++n) {
const std::string candidate = base + " " + std::to_string(n);
const std::string candKey = nameKey(candidate);
if (!taken(candKey)) {
b.displayName = candidate;
key = candKey;
break;
}
}
}
seenKeys.push_back(key);
}
}
banks = std::move(parsedBanks);
return true;
}
} // namespace
void BankBook::adoptBanks(std::vector<Bank>&& banks, const std::string& activeBank) {
banks_ = std::move(banks);
normalizeOrdinals();
// Resolve the active bank defensively: fall back to the pool if the persisted id
// names no bank, so a corrupt active id never leaves a dangling capture target.
activeBankId_ = (bank(activeBank) != nullptr) ? activeBank : std::string(kPoolBankId);
}
std::optional<BankBook> BankBook::deserialize(const std::string& blob) {
std::vector<Bank> banks;
std::string activeBank;
json::Reader r(blob);
if (!parseBook(r, blob, banks, activeBank)) return std::nullopt;
BankBook book;
book.adoptBanks(std::move(banks), activeBank);
return book;
}
// ---------------------------------------------------------------------------
// Active-bank cycle ordering (pure, free function — mirror of nextModeId)
// ---------------------------------------------------------------------------
std::string nextBankId(const std::vector<std::string>& 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