#include "bank_book.h" #include #include #include // bank_book implementation. // // JSON is hand-rolled and self-contained, matching the house style of bank_model // and view_mode_model (brief: keep the pure core dependency-free — no third-party // JSON lib). The book blob nests one bank object per bank, each carrying that // bank's BankIndex serialized by bank_model's OWN writer (BankIndex::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 BankIndex blob verbatim; the parser splits // the book envelope, then hands each nested index blob straight to // BankIndex::deserialize. Ints use %d; strings are escaped by writeEscaped. namespace reasampler { // --------------------------------------------------------------------------- // 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; } BankIndex* BankBook::index(const std::string& id) { Bank* b = bank(id); return b ? &b->index : nullptr; } const BankIndex* 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) // --------------------------------------------------------------------------- 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(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(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 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 — BankIndex has no bulk move, // and adding into the pool must not alias the vector we are draining. BankIndex& 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 = BankIndex{}; // 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; } BankIndex& BankBook::activeIndex() { // activeBankId_ always names a live bank; it falls back to the pool on delete. return bank(activeBankId_)->index; } const BankIndex& BankBook::activeIndex() const { return bank(activeBankId_)->index; } // --------------------------------------------------------------------------- // Sample movement (index-only) // --------------------------------------------------------------------------- namespace { // Adds `s` to `dest` and maps the BankIndex 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(BankIndex& 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)) 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; return from->index.remove(sampleId) ? RemoveResult::Removed : RemoveResult::RejectedSampleAbsent; } 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 } 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 — writer // =========================================================================== namespace { void writeEscaped(std::string& out, const std::string& s) { out += '"'; for (char c : s) { switch (c) { case '"': out += "\\\""; break; case '\\': out += "\\\\"; break; case '\b': out += "\\b"; break; case '\f': out += "\\f"; break; case '\n': out += "\\n"; break; case '\r': out += "\\r"; break; case '\t': out += "\\t"; break; default: if (static_cast(c) < 0x20) { char buf[8]; std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); out += buf; } else { out += c; } } } out += '"'; } std::string intToStr(int v) { char buf[16]; std::snprintf(buf, sizeof(buf), "%d", v); return buf; } class ObjWriter { public: explicit ObjWriter(std::string& out) : out_(out) { out_ += '{'; } ~ObjWriter() { out_ += '}'; } void keyRaw(const char* key, const std::string& rawValue) { sep(); writeEscaped(out_, key); out_ += ':'; out_ += rawValue; } void keyStr(const char* key, const std::string& value) { sep(); writeEscaped(out_, key); out_ += ':'; writeEscaped(out_, value); } void keyBegin(const char* key) { sep(); writeEscaped(out_, key); out_ += ':'; } private: void sep() { if (first_) first_ = false; else out_ += ','; } std::string& out_; bool first_ = true; }; } // 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: }. // 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 BankIndex::serialize (not duplicated). b.keyRaw("index", banks_[i].index.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 { class Parser { public: explicit Parser(const std::string& s) : s_(s) {} // Parses a book blob into a bank set + active id. On success fills the out-params // and returns true. Distinguishes 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 parseBook(std::vector& banks, std::string& activeBank); private: const std::string& s_; std::size_t pos_ = 0; bool eof() const { return pos_ >= s_.size(); } void skipWs() { while (!eof()) { char c = s_[pos_]; if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_; else break; } } bool consume(char c) { skipWs(); if (eof() || s_[pos_] != c) return false; ++pos_; return true; } bool parseString(std::string& out); bool parseInt(int& out); bool parseKey(std::string& key); bool skipValue(); // Captures the raw source text of one JSON value (object / array / string / // scalar) verbatim, so a nested BankIndex blob can be handed to its own parser. bool captureValue(std::string& raw); bool parseBank(Bank& out); }; bool Parser::parseString(std::string& out) { skipWs(); if (eof() || s_[pos_] != '"') return false; ++pos_; out.clear(); while (!eof()) { char c = s_[pos_++]; if (c == '"') return true; if (c == '\\') { if (eof()) return false; char e = s_[pos_++]; switch (e) { case '"': out += '"'; break; case '\\': out += '\\'; break; case '/': out += '/'; break; case 'b': out += '\b'; break; case 'f': out += '\f'; break; case 'n': out += '\n'; break; case 'r': out += '\r'; break; case 't': out += '\t'; break; case 'u': { auto readHex4 = [&](unsigned int& cp) -> bool { if (pos_ + 4 > s_.size()) return false; cp = 0; for (int i = 0; i < 4; ++i) { char h = s_[pos_++]; cp <<= 4; if (h >= '0' && h <= '9') cp |= static_cast(h - '0'); else if (h >= 'a' && h <= 'f') cp |= static_cast(h - 'a' + 10); else if (h >= 'A' && h <= 'F') cp |= static_cast(h - 'A' + 10); else return false; } return true; }; unsigned int hi = 0; if (!readHex4(hi)) return false; unsigned int codePoint = hi; if (hi >= 0xD800 && hi <= 0xDBFF) { if (pos_ + 6 > s_.size()) return false; if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false; pos_ += 2; unsigned int lo = 0; if (!readHex4(lo)) return false; if (lo < 0xDC00 || lo > 0xDFFF) return false; codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); } else if (hi >= 0xDC00 && hi <= 0xDFFF) { return false; // unpaired low surrogate } if (codePoint <= 0x7F) { out += static_cast(codePoint); } else if (codePoint <= 0x7FF) { out += static_cast(0xC0 | (codePoint >> 6)); out += static_cast(0x80 | (codePoint & 0x3F)); } else if (codePoint <= 0xFFFF) { out += static_cast(0xE0 | (codePoint >> 12)); out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); out += static_cast(0x80 | (codePoint & 0x3F)); } else { out += static_cast(0xF0 | (codePoint >> 18)); out += static_cast(0x80 | ((codePoint >> 12) & 0x3F)); out += static_cast(0x80 | ((codePoint >> 6) & 0x3F)); out += static_cast(0x80 | (codePoint & 0x3F)); } break; } default: return false; } } else { out += c; } } return false; // unterminated } bool Parser::parseInt(int& out) { skipWs(); std::size_t start = pos_; if (!eof() && (s_[pos_] == '-' || s_[pos_] == '+')) ++pos_; std::size_t digitsStart = pos_; while (!eof() && s_[pos_] >= '0' && s_[pos_] <= '9') ++pos_; if (pos_ == digitsStart) return false; // no digits long v = 0; try { v = std::stol(s_.substr(start, pos_ - start)); } catch (...) { return false; // out of long range → malformed } if (v < INT_MIN || v > INT_MAX) return false; out = static_cast(v); return true; } bool Parser::parseKey(std::string& key) { if (!parseString(key)) return false; if (!consume(':')) return false; return true; } bool Parser::skipValue() { std::string raw; return captureValue(raw); } // Records the raw source span of one JSON value starting at the current position // (after whitespace) so it can be re-parsed by a nested parser. Handles nested // objects/arrays with string-aware brace matching (braces inside strings ignored). bool Parser::captureValue(std::string& raw) { skipWs(); if (eof()) return false; std::size_t start = pos_; char c = s_[pos_]; if (c == '"') { std::string tmp; if (!parseString(tmp)) return false; raw.assign(s_, start, pos_ - start); return true; } if (c == '{' || c == '[') { char open = c, close = (c == '{') ? '}' : ']'; ++pos_; int depth = 1; while (!eof() && depth > 0) { char d = s_[pos_]; if (d == '"') { std::string tmp; if (!parseString(tmp)) return false; // advances past the string continue; } if (d == open) ++depth; else if (d == close) --depth; ++pos_; } if (depth != 0) return false; raw.assign(s_, start, pos_ - start); return true; } // bare scalar (number / true / false / null) while (!eof()) { char d = s_[pos_]; if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' || d == '\n' || d == '\r') break; ++pos_; } if (pos_ == start) return false; raw.assign(s_, start, pos_ - start); return true; } bool Parser::parseBank(Bank& b) { if (!consume('{')) return false; skipWs(); if (consume('}')) return false; // a bank object must at least carry an id bool haveId = false; bool haveIndex = false; do { std::string key; if (!parseKey(key)) return false; if (key == "id") { if (!parseString(b.id)) return false; haveId = true; } else if (key == "displayName") { if (!parseString(b.displayName)) return false; } else if (key == "ordinal") { if (!parseInt(b.ordinal)) return false; } else if (key == "index") { std::string raw; if (!captureValue(raw)) return false; auto idx = BankIndex::deserialize(raw); if (!idx) return false; // a malformed nested index fails the whole parse b.index = std::move(*idx); haveIndex = true; } else { if (!skipValue()) return false; // forward-compat unknown keys } } while (consume(',')); if (!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 Parser::parseBook(std::vector& banks, std::string& activeBank) { banks.clear(); activeBank.clear(); if (!consume('{')) return false; skipWs(); if (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 parsedBanks; bool sawBanks = false; bool sawSamples = false; do { std::string key; if (!parseKey(key)) return false; if (key == "banks") { sawBanks = true; if (!consume('[')) return false; skipWs(); if (!consume(']')) { do { Bank b; if (!parseBank(b)) return false; parsedBanks.push_back(std::move(b)); } while (consume(',')); if (!consume(']')) return false; } } else if (key == "activeBank") { if (!parseString(activeBank)) return false; } else if (key == "samples") { // Legacy marker. The legacy index is re-parsed from the whole input below // (BankIndex::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 (!skipValue()) return false; } else { if (!skipValue()) return false; // version, or unknown } } while (consume(',')); if (!consume('}')) return false; skipWs(); if (!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 = BankIndex::deserialize(s_); 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 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&& 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::deserialize(const std::string& json) { std::vector banks; std::string activeBank; Parser p(json); if (!p.parseBook(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& 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