Merge Ε-W2: bank export and bank import, both verbs and both panel rows

Union of two parallel tracks. Both action rows, both menu rows, both link
edges survive; the two package CLAUDE.md files now describe the post-merge
reality rather than either side's pre-merge scope.
This commit is contained in:
2026-08-02 14:18:28 -04:00
30 changed files with 1918 additions and 44 deletions
+15 -3
View File
@@ -3,6 +3,8 @@
#include <algorithm>
#include <unordered_set>
#include "core/util/ascii_ws.h"
// 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,
@@ -76,9 +78,8 @@ void BankBook::normalizeOrdinals() {
// 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;
while (b < e && util::isAsciiWs(s[b])) ++b;
while (e > b && util::isAsciiWs(s[e - 1])) --e;
std::string out;
out.reserve(e - b);
for (std::size_t i = b; i < e; ++i) {
@@ -117,6 +118,17 @@ bool BankBook::createBank(const std::string& id, const std::string& displayName)
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);
+11
View File
@@ -100,6 +100,17 @@ public:
// 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.
+32 -10
View File
@@ -7,7 +7,7 @@ unit-tested outside the DAW): the format contract and version ladder, the JSON
manifest, and the framing/layout codec. No filesystem — the shell
(`src/shell/package`) streams bytes against the layouts produced here. The
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
`export_plan` has landed, `import_plan` has not.
both have landed.
## Invariants
@@ -79,6 +79,14 @@ export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
(missing / unreadable / an index record the format cannot represent). Owns the
name repair the codec's refusal backstops, and normalizes each shipping record's
`relativePath` to the bare package name — see the transport-name gotcha below.
- `import_plan` — the pure import decision, and the reason the whole feature is
testable without a DAW: the destination bank's display name after
`BankBook`'s own fold, the reminted sample ids and remapped parents, and the
per-entry land / collapse / rename disposition. Also `importLedgerRefusal` (the
import's ledger gate, delegating entirely to `tracking::ledgerDegraded`) and
`ledgerRefusalMessage` (the gate's console-block body, a pure
`(LedgerRefusal, namespace) -> string` fold the shell only supplies the
channel-correct namespace to).
- `bank_package` — framing and arithmetic composing the two above:
`encodePackage` (prefix bytes + layout + total size, stamping this build's
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
@@ -97,6 +105,11 @@ export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
codec rejects only what makes the container itself incoherent (duplicate
entry names, invalid names, a non-single-sample nested index).
- **`import_plan` consults no other bank's hashes, and that is the ruling, not
an omission.** An import always creates a NEW bank, so "already present in the
destination bank by content" is exactly "already landed by this same plan".
Cross-bank dedup is not enforced anywhere (`core/model/CLAUDE.md`), so a hash
the pool already holds still lands its own file here.
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
version pair classifies `Readable`; for `TooNew` it stops at the semver —
don't "fix" it to read the manifest length there, a future structural format
@@ -155,18 +168,27 @@ export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
collision class as the ASCII case fold, which `sameEntryName` does catch. A
table-free fix does not exist, and restricting names to ASCII would be
genuinely over-strict for non-English users. Left open knowingly.
- **`duplicateName` is O(n²) over `entries` on the decode path** — pre-existing
shape (the double loop is unchanged since `af35fc5`; only the comparator
changed). Under the `kMaxManifestBytes` cap (64 MB) a minimal entry is
~100 bytes, so a hostile package can declare ~670k entries — ~2×10¹¹ pair
comparisons, a multi-minute hang on import. It signals an error rather than
UB, so the hostile-input invariant above still holds, but it sits against
this module's "a forged header cannot demand gigabytes" posture. Forward
obligation for `import_plan`: fold this into a sorted vector or hash set
when that track lands; not changed here.
- **`duplicateName` folds through a hash set, not a pairwise scan.** Under the
`kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile
package can declare ~670k entries; the former double loop was ~2×10¹¹ pair
comparisons — a multi-minute hang on the decode path an import drives. The
set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold
made explicit, so the equivalence rule still has one home (`lowerAscii`).
Do not reintroduce the pairwise scan.
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
entry cannot round-trip through the filesystem seam there (`appendPayload`
refuses an empty payload — an empty buffer signals an upstream read failure,
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
at encode so this layer never produces one; decode does not enforce it (a
hostile/older package declaring one is not this track's concern).
- **`import_plan`'s `spelledLikeABankFile` mints a fresh name even with NO
collision, and that third condition is a deliberate decision, not spec-derived.**
`docs/product/bank-package.md:447` ties the auto-rename mint to a *collision*
only; `spelledLikeABankFile` additionally mints whenever the package's own name
isn't spelled the way `deriveBankPaths` spells one (extension, sanitized stem).
Kept for two reasons: uniform folder spelling for every landed file regardless of
origin, and — the sharper one — a hostile entry name that isn't a legal Windows
filename or carries an unexpected extension (e.g. `evil.exe`) lands sanitized
(`evil_<tag>.wav`) rather than verbatim. `ImportPlan` counts this separately from a
genuine folder-name collision (`sanitizeRenameCount` vs `collisionRenameCount`) so
the summary line means what `bank-package.md:447` says it means.
+7
View File
@@ -17,3 +17,10 @@ reasampler_pure_library(bank_package
LINK PUBLIC package_format package_manifest PRIVATE app_version)
# app_version: the tests pin the stamped writer semver against stampVersion().
reasampler_test(bank_package LINK bank_package app_version)
reasampler_pure_library(import_plan
SOURCES import_plan.cpp
LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths)
# tracking_authority: the ledger-gate test proves the import does NOT share prune's
# composite blocker, which needs the composite to compare against.
reasampler_test(import_plan LINK import_plan tracking_authority)
+221
View File
@@ -0,0 +1,221 @@
#include "core/package/import_plan.h"
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include "core/capture/capture_paths.h"
#include "core/package/package_format.h"
#include "core/util/ascii_ws.h"
namespace reasampler::package {
namespace {
using capture::bankRelativeForName;
using capture::deriveBankPaths;
using capture::sanitizeStem;
using util::isAsciiWs;
// Shares BankBook::nameKey's whitespace set (core/util/ascii_ws.h) so a name nameKey
// would fold to empty is never treated as recorded here.
bool blankName(const std::string& s) {
for (char c : s)
if (!isAsciiWs(c)) return false;
return true;
}
// "kick.wav" -> "kick"; a name with no dot is its own stem. deriveBankPaths re-adds
// the extension, so handing it the full name would file "kick.wav" as "kick.wav.wav".
std::string stemOf(const std::string& fileName) {
const std::size_t dot = fileName.rfind('.');
if (dot == std::string::npos || dot == 0) return fileName;
return fileName.substr(0, dot);
}
// True when `fileName` is already spelled the way this tool spells a bank file, so a
// package landing in a fresh project keeps the names it travelled with. Anything else
// is minted through deriveBankPaths, which is also the sanitizer.
bool spelledLikeABankFile(const std::string& fileName) {
const std::string stem = stemOf(fileName);
return stem != fileName && sanitizeStem(stem) == stem && fileName == stem + ".wav";
}
// The bank-folder names an import must not land on: what is there already, plus what
// this import has minted so far. Case-folded, because the two filesystems this tool
// ships on would treat "Kick.wav" and "kick.wav" as one file.
//
// `bankFolderFileNames` comes from `listFolderFileNames` (shell/package/package_io),
// which skips non-regular files — so a DIRECTORY sharing a bank file's name is
// invisible here. The plan then never mints around it, and the later exclusive-create
// land fails on that one entry (WriteFailed, rolled back). Safe direction ("never
// overwrite" still holds) but worth knowing before chasing a WriteFailed report that
// traces back to a same-named folder in the bank directory; test_import_landing's
// rollback suite deliberately exploits this to exercise the rollback path.
class NameSet {
public:
explicit NameSet(const std::vector<std::string>& present) {
keys_.reserve(present.size());
for (const std::string& n : present) keys_.insert(entryNameKey(n));
}
bool taken(const std::string& name) const { return keys_.count(entryNameKey(name)) != 0; }
void claim(const std::string& name) { keys_.insert(entryNameKey(name)); }
private:
std::unordered_set<std::string> keys_;
};
// The name this entry lands under. Terminates: each attempt carries a distinct
// counter, and the taken set is finite.
std::string mintFileName(const std::string& projectDir, const std::string& packageName,
const std::string& uniqueTag, const NameSet& taken) {
if (spelledLikeABankFile(packageName) && !taken.taken(packageName)) return packageName;
const std::string stem = stemOf(packageName);
std::string tag = uniqueTag;
for (int n = 2;; ++n) {
const std::string candidate = deriveBankPaths(projectDir, stem, tag).fileName;
if (!taken.taken(candidate)) return candidate;
tag = uniqueTag + "-" + std::to_string(n);
}
}
} // namespace
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status) {
// Delegates the refuse/proceed decision entirely to ledgerDegraded() rather than
// re-deriving it from the two named statuses, so a future degraded status added
// there is refused here too rather than silently falling through to None.
if (!tracking::ledgerDegraded(status)) return LedgerRefusal::None;
// Below this point status is known degraded; only the message variant is picked.
// Unreadable gets its own "corrupt, may be cleared" wording; every other degraded
// status (today only FutureVersion) gets the "written by a newer build" wording.
return status == tracking::LedgerStatus::Unreadable ? LedgerRefusal::Malformed
: LedgerRefusal::FutureVersion;
}
// Mirrors prune's abort block in structure and tone (shell/actions/prune_action.cpp),
// because a user who has hit that one should recognise this one. Every recovery line
// names THIS build's namespace: a beta user handed the stable spelling clears the wrong
// key and is still blocked.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace) {
if (refusal == LedgerRefusal::None) return {};
std::string msg =
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
"Nothing was imported.\n";
if (refusal == LedgerRefusal::Malformed) {
msg += "The stored file-tracking ledger is malformed. It has been left intact "
"rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"" + extStateNamespace + "\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost. Reopen the project "
"afterwards -- the block is held for the rest of this session.\n";
} else {
msg += "The stored file-tracking ledger was written by a NEWER version of "
"ReaSampler than this one, so its records cannot be read safely. It has "
"been left intact and will NOT be overwritten. Reopen the project with "
"that newer version -- do NOT clear this key from here, that would "
"discard tracking records this build cannot see. The block is held for "
"the rest of this session.\n";
}
msg += "An import can land hundreds of files in one gesture. With no readable "
"ledger, none of them could be given a birth record, and every one would be "
"permanently unreclaimable.\n";
return msg;
}
std::string bankFolderDir(const std::string& projectDir) {
// Only the directory half of the result is wanted; the stem is a placeholder.
return deriveBankPaths(projectDir, "bank", std::string{}).absoluteDir;
}
ImportPlan planImport(const PackageManifest& manifest,
const BankBook& destination,
const std::string& projectDir,
const std::vector<std::string>& bankFolderFileNames,
const std::string& uniqueTag) {
ImportPlan plan;
plan.seedBankName = blankName(manifest.bankDisplayName)
? std::string(kDefaultImportBankName)
: manifest.bankDisplayName;
plan.bankDisplayName = destination.uniqueDisplayName(plan.seedBankName);
plan.bankNameAdjusted = plan.bankDisplayName != plan.seedBankName;
NameSet taken(bankFolderFileNames);
// The destination bank is created empty by this same import, so "already in the
// destination bank by content" is exactly "already landed by this plan" — the
// hash set below IS that bank's findByHash. Cross-bank dedup is deliberately not
// enforced (core/model/CLAUDE.md), so other banks' hashes are not consulted.
std::unordered_map<std::string, std::string> landedIdForHash;
// Every package id, including a collapsed one's, so a parent link that pointed at
// a duplicate still resolves to the entry that survived it.
std::unordered_map<std::string, std::string> idRemap;
plan.entries.reserve(manifest.entries.size());
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
const PackageEntry& src = manifest.entries[i];
PlannedEntry e;
e.manifestIndex = i;
const std::string& hash = src.sample.contentHash;
if (!hash.empty()) {
const auto hit = landedIdForHash.find(hash);
if (hit != landedIdForHash.end()) {
e.action = EntryAction::Collapse;
idRemap[src.sample.id] = hit->second;
++plan.collapseCount;
plan.entries.push_back(std::move(e));
continue;
}
}
e.destFileName = mintFileName(projectDir, src.fileName, uniqueTag, taken);
e.renamed = e.destFileName != src.fileName;
taken.claim(e.destFileName);
e.sample = src.sample;
e.sample.id = std::string(kImportIdPrefix) + uniqueTag + "-" + e.destFileName;
e.sample.relativePath = bankRelativeForName(e.destFileName);
if (!hash.empty()) landedIdForHash.emplace(hash, e.sample.id);
idRemap[src.sample.id] = e.sample.id;
++plan.landCount;
// A rename happens for one of two reasons: the package's own name was already
// taken (spelledLikeABankFile true but the mint's fast path lost the race to
// `taken`), or the name never qualified for that fast path at all (sanitize).
if (e.renamed) {
if (spelledLikeABankFile(src.fileName)) ++plan.collisionRenameCount;
else ++plan.sanitizeRenameCount;
}
plan.entries.push_back(std::move(e));
}
// Second pass: the remap must be complete before a parent is resolved, since a
// sample may precede its own parent in manifest order.
for (PlannedEntry& e : plan.entries) {
if (e.action != EntryAction::Land || !e.sample.provenance) continue;
const auto hit = idRemap.find(e.sample.provenance->parentSampleId);
e.sample.provenance->parentSampleId =
hit == idRemap.end() ? std::string{} : hit->second;
}
// The package's display order, over the ids that actually landed. SlotMap's own
// repair rules settle the rest: two package ids collapsed onto one landed id give
// one slot (first wins), and a landed sample the package never positioned is
// appended by BankBook::reconcileSlots afterwards.
std::vector<std::pair<std::string, int>> slotPairs;
for (const std::string& oldId : manifest.slots.orderedIds()) {
const auto hit = idRemap.find(oldId);
if (hit == idRemap.end()) continue;
slotPairs.emplace_back(hit->second, manifest.slots.slotOf(oldId));
}
plan.slots = model::SlotMap::fromEntries(slotPairs);
return plan;
}
} // namespace reasampler::package
+88
View File
@@ -0,0 +1,88 @@
#pragma once
// import_plan — the pure import decision: the destination bank's display name after
// the book's own uniqueness fold, the reminted sample ids and remapped parents, and
// the per-entry land / collapse / rename disposition. Value inputs only; no
// filesystem, no session handle, no host types.
#include <cstddef>
#include <string>
#include <vector>
#include "core/model/bank_book.h"
#include "core/model/slot_map.h"
#include "core/package/package_manifest.h"
#include "core/tracking/origin_ledger.h"
namespace reasampler::package {
// The bank name a package that recorded none (or a blank one) imports under.
inline constexpr const char* kDefaultImportBankName = "Imported bank";
// The prefix every imported sample id is reminted under, so a package's own ids —
// unique only within the project that made them — never enter this index.
inline constexpr const char* kImportIdPrefix = "pkg-";
// Which of the two refusal messages the import owes the user, if either.
//
// Keyed on the LEDGER STATUS ALONE, never on prune's composite blockedByTracking:
// that flag also fires on undecodable rsusage_* keys, which govern which files a
// DELETION may touch. An import deletes nothing and computes no protected set — it
// writes birth records — so an unreadable usage key must not refuse one.
enum class LedgerRefusal { None, Malformed, FutureVersion };
LedgerRefusal importLedgerRefusal(tracking::LedgerStatus status);
// The console-block body for a refusal — a pure (LedgerRefusal, namespace) -> string
// fold, so the wording is assertable without a DAW. `extStateNamespace` is the
// channel-correct namespace (`version::extStateNamespace()`) every recovery line must
// name, so a beta user is never handed the stable spelling. Empty string for None —
// callers only reach this once `importLedgerRefusal` has already returned a refusal.
std::string ledgerRefusalMessage(LedgerRefusal refusal, const std::string& extStateNamespace);
// What one manifest entry does when the import runs.
// - Land: write the payload under destFileName and add `sample`.
// - Collapse: an equal contentHash already lands in this same import, so the payload
// is NOT written and no entry is added. Writing it and letting
// BankModel::add collapse the entry would leave the file referenced by
// nothing — an orphan manufactured by a dedup.
enum class EntryAction { Land, Collapse };
struct PlannedEntry {
std::size_t manifestIndex = 0;
EntryAction action = EntryAction::Land;
std::string destFileName; // Land only — a bare name in the bank folder
model::Sample sample; // Land only — id, path and parent already remapped
bool renamed = false; // the package's own name was taken, so a fresh one was minted
};
struct ImportPlan {
std::string bankDisplayName;
bool bankNameAdjusted = false; // the seed was taken, so the name carries a suffix
std::string seedBankName; // the seed the probe started from
std::vector<PlannedEntry> entries; // one per manifest entry, in manifest order
model::SlotMap slots; // the package's slots over the reminted ids
int landCount = 0;
int collapseCount = 0;
// Two distinct triggers, counted separately (bank-package.md:447 defines the first
// as THE collision counter; conflating the second into it would misreport a mint
// that never collided as a collision).
int collisionRenameCount = 0; // the package's own name was already taken in the bank folder
int sanitizeRenameCount = 0; // the package's name was not spelled the way this tool spells
// a bank file (see spelledLikeABankFile, core/package/CLAUDE.md)
};
// The bank folder an import lands into — the same expression capture uses, so an
// imported file is spelled exactly like a captured one.
std::string bankFolderDir(const std::string& projectDir);
// Decides everything about an import except the bytes. `bankFolderFileNames` are the
// bare names already present in that folder (never overwritten); `uniqueTag` is the
// shell's per-import disambiguator, extended with an ascending counter where one tag
// is not enough. Total: every manifest entry yields exactly one PlannedEntry.
ImportPlan planImport(const PackageManifest& manifest,
const BankBook& destination,
const std::string& projectDir,
const std::vector<std::string>& bankFolderFileNames,
const std::string& uniqueTag);
} // namespace reasampler::package
+7
View File
@@ -103,6 +103,13 @@ bool sameEntryName(const std::string& a, const std::string& b) {
return true;
}
std::string entryNameKey(const std::string& name) {
std::string key;
key.reserve(name.size());
for (unsigned char c : name) key += lowerAscii(c);
return key;
}
bool isValidNestedSamplePath(const std::string& path) {
if (util::isAbsolutePath(path)) return false;
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
+6
View File
@@ -76,6 +76,12 @@ bool isValidEntryName(const std::string& name);
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
bool sameEntryName(const std::string& a, const std::string& b);
// sameEntryName's fold made explicit: the ASCII-lower-cased bytes, so
// entryNameKey(a) == entryNameKey(b) exactly when sameEntryName(a, b). For a caller
// holding many names at once — folding them into a set is what turns an O(n^2)
// pairwise scan into a linear one.
std::string entryNameKey(const std::string& name);
// The one field in the format that CAN express a path: a nested Sample's
// relativePath, which is bank-relative by design. Rejects every absolute form
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
+10 -4
View File
@@ -1,5 +1,6 @@
#include "core/package/package_manifest.h"
#include <unordered_set>
#include <utility>
#include "core/json/json.h"
@@ -14,11 +15,16 @@ using ObjWriter = json::Writer;
// Shared by serializeManifest and deserializeManifest — see this directory's
// CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the
// format's, not std::string's: sameEntryName folds ASCII case.
// format's, not std::string's: entryNameKey is sameEntryName's ASCII-case fold.
//
// A set, not the pairwise scan this replaced: under kMaxManifestBytes a hostile
// package can declare hundreds of thousands of minimal entries, and O(n^2) over that
// is a multi-minute hang on the decode path an import drives.
bool duplicateName(const std::vector<PackageEntry>& entries) {
for (std::size_t i = 0; i < entries.size(); ++i)
for (std::size_t j = i + 1; j < entries.size(); ++j)
if (sameEntryName(entries[i].fileName, entries[j].fileName)) return true;
std::unordered_set<std::string> seen;
seen.reserve(entries.size());
for (const auto& e : entries)
if (!seen.insert(entryNameKey(e.fileName)).second) return true;
return false;
}
+3 -2
View File
@@ -3,8 +3,8 @@
## Scope
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
loading, unit-interval clamping, the absolute-path rejection test, and the
per-segment envelope curve law.
loading, unit-interval clamping, the absolute-path rejection test, the
per-segment envelope curve law, and the ASCII-whitespace fold test.
## Modules
@@ -19,6 +19,7 @@ per-segment envelope curve law.
before curves existed play unchanged, and what the knob law's centre detent exists to keep
reachable from the dial.
- `relative_path` (`core/util`, header-only) — the ONE absolute-path rejection test behind the relative-paths-only invariant, shared by `bank_model` (`Sample.relativePath`) and `core/tracking/origin_ledger` (`OriginRecord.relativePath`). The two must reject identically or a path one accepts could be smuggled past the other; that is why it is one function and not two.
- `ascii_ws` (`core/util`, header-only) — the ONE ASCII-whitespace test (space/tab/CR/LF) behind `BankBook::nameKey`'s trim, shared by `core/package/import_plan`'s blank-bank-name fallback. Same rationale as `relative_path`: two independently-maintained copies could drift on what counts as blank.
## Gotchas
+10
View File
@@ -0,0 +1,10 @@
#pragma once
// ascii_ws — the ONE ASCII-whitespace test shared by every fold that must agree with
// BankBook::nameKey's trim (space/tab/CR/LF): a drifted second copy could accept a
// package bank name nameKey would treat as blank, or vice versa.
namespace reasampler::util {
inline bool isAsciiWs(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
} // namespace reasampler::util