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:
+9
-6
@@ -2366,7 +2366,7 @@ These bind every track in this phase, in addition to the plan-wide set above.
|
|||||||
touching it must **cite, not restate**.
|
touching it must **cite, not restate**.
|
||||||
- **Export is read-only against the project.** No ext-state write, no `bumpBankGeneration()`,
|
- **Export is read-only against the project.** No ext-state write, no `bumpBankGeneration()`,
|
||||||
no undo point. Import does the opposite: it bumps the generation
|
no undo point. Import does the opposite: it bumps the generation
|
||||||
(`src/shell/persist/session.h:108`) so live ReaSampler 9000 instances reload, and batches
|
(`src/shell/persist/session.h:114`) so live ReaSampler 9000 instances reload, and batches
|
||||||
its index mutation into one Ctrl-Z through `persistBankOp`.
|
its index mutation into one Ctrl-Z through `persistBankOp`.
|
||||||
- **All-or-nothing on both sides.** No partial export, no partial import. A truncated
|
- **All-or-nothing on both sides.** No partial export, no partial import. A truncated
|
||||||
`.rsbank` must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a
|
`.rsbank` must never exist on disk (temp file + atomic rename, the Ψ-W2-T2 precedent); a
|
||||||
@@ -2396,10 +2396,13 @@ new directories** (`src/core/package/`, `src/shell/package/`) that no other phas
|
|||||||
not read here. The only pre-existing files any Ε track edits are named per track below —
|
not read here. The only pre-existing files any Ε track edits are named per track below —
|
||||||
`core/tracking/origin_ledger` (W1-T3, exclusively), the root `CMakeLists.txt`
|
`core/tracking/origin_ledger` (W1-T3, exclusively), the root `CMakeLists.txt`
|
||||||
`add_subdirectory` list (W1-T1 and W1-T2, one line each), `src/app/main.cpp` and the panel's
|
`add_subdirectory` list (W1-T1 and W1-T2, one line each), `src/app/main.cpp` and the panel's
|
||||||
bank menu (W2-T1 and W2-T2, one registration line and one menu row each), and
|
bank menu (W2-T1 and W2-T2, one registration line and one menu row each),
|
||||||
`core/model/bank_book.{h,cpp}` (W2-T2 only — **one additive public `const` member**, required
|
`core/model/bank_book.{h,cpp}` (W2-T2 only — **one additive public `const` member**, required
|
||||||
by the Ε-F2 auto-suffix rule so the name fold keeps its single home). **No Ε track
|
by the Ε-F2 auto-suffix rule so the name fold keeps its single home), and
|
||||||
touches `core/instrument/`, `shell/instrument/`, or any capture backend.**
|
`shell/persist/session.h` (W2-T2 only — one additive public accessor, `ledgerStatus()`, so the
|
||||||
|
import gate can key on `LedgerStatus` alone without going through `pruneDryRun()`'s
|
||||||
|
enumeration+scan). **No Ε track touches `core/instrument/`, `shell/instrument/`, or any
|
||||||
|
capture backend.**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2766,7 +2769,7 @@ fold, it does not add a second one), `origin_ledger` (Ε-W1-T3's).
|
|||||||
`"Drums 3"` — a bare trailing integer is indistinguishable from `"Kit 808"`); the probe
|
`"Drums 3"` — a bare trailing integer is indistinguishable from `"Kit 808"`); the probe
|
||||||
**fills gaps** (first-free, not highest-plus-one, so it is a pure function of the current
|
**fills gaps** (first-free, not highest-plus-one, so it is a pure function of the current
|
||||||
name set); the probe **terminates** by pigeonhole within `B + 1` candidates for `B` banks,
|
name set); the probe **terminates** by pigeonhole within `B + 1` candidates for `B` banks,
|
||||||
so **no arbitrary cap**; and the fold is `BankBook`'s own (`bank_book.h:252-258`), reached
|
so **no arbitrary cap**; and the fold is `BankBook`'s own (`bank_book.h:263-269`), reached
|
||||||
through the new public member, never re-implemented in `import_plan`. Sample display names
|
through the new public member, never re-implemented in `import_plan`. Sample display names
|
||||||
are **not** suffixed, and `slot_map` positions are untouched.
|
are **not** suffixed, and `slot_map` positions are untouched.
|
||||||
- **Always a new bank; never a merge (Ε-F2, ruled).** The import creates a bank — it never
|
- **Always a new bank; never a merge (Ε-F2, ruled).** The import creates a bank — it never
|
||||||
@@ -2789,7 +2792,7 @@ fold, it does not add a second one), `origin_ledger` (Ε-W1-T3's).
|
|||||||
remain as orphans until a prune reclaims them — the same designed window a non-empty bank
|
remain as orphans until a prune reclaims them — the same designed window a non-empty bank
|
||||||
delete already produces (`core/model/CLAUDE.md`'s sample-removal section). The user-facing
|
delete already produces (`core/model/CLAUDE.md`'s sample-removal section). The user-facing
|
||||||
summary says so.
|
summary says so.
|
||||||
- **`bumpBankGeneration()` on success** (`session.h:108`), so live instances reload.
|
- **`bumpBankGeneration()` on success** (`session.h:114`), so live instances reload.
|
||||||
- **No timeline item is placed. Ever.**
|
- **No timeline item is placed. Ever.**
|
||||||
- **A new FOREVER-STABLE command id**, minted the same way T1's is.
|
- **A new FOREVER-STABLE command id**, minted the same way T1's is.
|
||||||
|
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ trim, the seed is the literal `Imported bank`.
|
|||||||
|
|
||||||
**The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness
|
**The probe.** Let `seed` be that string and `fold(x)` be `BankBook`'s own uniqueness
|
||||||
key — strip leading/trailing ASCII whitespace, lower-case ASCII letters
|
key — strip leading/trailing ASCII whitespace, lower-case ASCII letters
|
||||||
(`bank_book.h:252-258`). Take the **first** name in this sequence whose fold is not
|
(`bank_book.h:263-269`). Take the **first** name in this sequence whose fold is not
|
||||||
already carried by a bank in the destination book:
|
already carried by a bank in the destination book:
|
||||||
|
|
||||||
seed, seed + " 2", seed + " 3", seed + " 4", …
|
seed, seed + " 2", seed + " 3", seed + " 4", …
|
||||||
@@ -410,7 +410,7 @@ implementations diverge:**
|
|||||||
`B + 1` candidates is free by pigeonhole, so no cap is needed and none should be
|
`B + 1` candidates is free by pigeonhole, so no cap is needed and none should be
|
||||||
added.
|
added.
|
||||||
4. **The fold has exactly one home.** `import_plan` must **not** re-implement
|
4. **The fold has exactly one home.** `import_plan` must **not** re-implement
|
||||||
`nameKey` — `bank_book.h:252-258` says in as many words that a drifted second copy
|
`nameKey` — `bank_book.h:263-269` says in as many words that a drifted second copy
|
||||||
would let the uniqueness invariant be violated. The probe therefore runs behind
|
would let the uniqueness invariant be violated. The probe therefore runs behind
|
||||||
`BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const`
|
`BankBook`'s own folding, which means Ε-W2-T2 adds **one additive public `const`
|
||||||
member** to `BankBook` (recommended: `std::string uniqueDisplayName(const
|
member** to `BankBook` (recommended: `std::string uniqueDisplayName(const
|
||||||
@@ -690,7 +690,7 @@ constructors.
|
|||||||
freshly-generated pair.
|
freshly-generated pair.
|
||||||
- **Bank generation.** Import mutates bank content that live ReaSampler 9000
|
- **Bank generation.** Import mutates bank content that live ReaSampler 9000
|
||||||
instances may play, so it must `bumpBankGeneration()`
|
instances may play, so it must `bumpBankGeneration()`
|
||||||
(`src/shell/persist/session.h:108`, whose own comment says call sites "err toward
|
(`src/shell/persist/session.h:114`, whose own comment says call sites "err toward
|
||||||
bumping"). Export mutates nothing and must bump nothing, write no ext state, and
|
bumping"). Export mutates nothing and must bump nothing, write no ext state, and
|
||||||
open no undo point.
|
open no undo point.
|
||||||
- **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is
|
- **Beta/stable channel isolation.** Packages are channel-**agnostic** and this is
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model
|
|||||||
# free of the voice engine — a link edge to it here means the design drifted.
|
# free of the voice engine — a link edge to it here means the design drifted.
|
||||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
|
|
||||||
|
# Bank-package import: the promptless verb plus its action skin. Kept as its own
|
||||||
|
# appended block rather than merged into the lists above, so the two package
|
||||||
|
# directions stay textually independent.
|
||||||
|
target_sources(reaper_reasampler PRIVATE
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/package/import_bank.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/actions/package_import_action.cpp)
|
||||||
|
target_link_libraries(reaper_reasampler PRIVATE import_landing package_pickers)
|
||||||
|
|
||||||
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
|
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
|
||||||
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
|
# configs, since REAPER dlopen's any reaper_* module and the two channels' artifacts load
|
||||||
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
|
# side-by-side. LIBRARY_OUTPUT_DIRECTORY pins the module to the top of the build tree even
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "shell/actions/bank_actions.h" // multi-bank action family
|
#include "shell/actions/bank_actions.h" // multi-bank action family
|
||||||
#include "shell/actions/design_view_actions.h" // Design View action family
|
#include "shell/actions/design_view_actions.h" // Design View action family
|
||||||
#include "shell/actions/package_export_action.h" // bank-package export action body
|
#include "shell/actions/package_export_action.h" // bank-package export action body
|
||||||
|
#include "shell/actions/package_import_action.h" // bank-package import action body
|
||||||
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
|
#include "core/wire/bake_wire.h" // kBakeActionSuffix (the shared action id)
|
||||||
#include "shell/capture/bake_land.h" // resample-bake landing action body
|
#include "shell/capture/bake_land.h" // resample-bake landing action body
|
||||||
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
#include "shell/capture/capture_batch.h" // batch + recapture action bodies
|
||||||
@@ -93,6 +94,7 @@ static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_sess
|
|||||||
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
|
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
|
||||||
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
|
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
|
||||||
static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
|
static void RunExportBankPackage(int) { reasampler::doBankPackageExport(g_session, g_session.book().activeBankId()); }
|
||||||
|
static void RunImportBankPackage(int) { reasampler::doImportBankPackage(g_session); }
|
||||||
static void RunShowVersion(int) {
|
static void RunShowVersion(int) {
|
||||||
// On-demand only — no unconditional startup print (routine console chatter pops
|
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||||
// the console window).
|
// the console window).
|
||||||
@@ -151,6 +153,8 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
|||||||
&RunResampleBake});
|
&RunResampleBake});
|
||||||
rows.push_back({"EXPORT_BANK_PACKAGE", "export active bank as package",
|
rows.push_back({"EXPORT_BANK_PACKAGE", "export active bank as package",
|
||||||
&RunExportBankPackage});
|
&RunExportBankPackage});
|
||||||
|
rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)",
|
||||||
|
&RunImportBankPackage});
|
||||||
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
|
#include "core/util/ascii_ws.h"
|
||||||
|
|
||||||
// bank_book implementation — the registry RULES half: construction, pool
|
// bank_book implementation — the registry RULES half: construction, pool
|
||||||
// privileges, bank lifecycle, active bank, sample movement/removal, slot order,
|
// 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,
|
// 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.
|
// one folding rule shared with bank_book_json.cpp's parse-time coalesce.
|
||||||
std::string BankBook::nameKey(const std::string& s) {
|
std::string BankBook::nameKey(const std::string& s) {
|
||||||
std::size_t b = 0, e = s.size();
|
std::size_t b = 0, e = s.size();
|
||||||
auto isWs = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
|
while (b < e && util::isAsciiWs(s[b])) ++b;
|
||||||
while (b < e && isWs(s[b])) ++b;
|
while (e > b && util::isAsciiWs(s[e - 1])) --e;
|
||||||
while (e > b && isWs(s[e - 1])) --e;
|
|
||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(e - b);
|
out.reserve(e - b);
|
||||||
for (std::size_t i = b; i < e; ++i) {
|
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;
|
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) {
|
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
|
||||||
if (id == kPoolBankId) return false; // pool is un-renamable
|
if (id == kPoolBankId) return false; // pool is un-renamable
|
||||||
Bank* b = bank(id);
|
Bank* b = bank(id);
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ public:
|
|||||||
// no-op success.
|
// no-op success.
|
||||||
bool renameBank(const std::string& id, const std::string& displayName);
|
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
|
// 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
|
// 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.
|
// ordinals compact after; if the deleted bank was active, falls back to the pool.
|
||||||
|
|||||||
+32
-10
@@ -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
|
manifest, and the framing/layout codec. No filesystem — the shell
|
||||||
(`src/shell/package`) streams bytes against the layouts produced here. The
|
(`src/shell/package`) streams bytes against the layouts produced here. The
|
||||||
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
|
export/import *decisions* (`export_plan` / `import_plan`) are separate modules;
|
||||||
`export_plan` has landed, `import_plan` has not.
|
both have landed.
|
||||||
|
|
||||||
## Invariants
|
## 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
|
(missing / unreadable / an index record the format cannot represent). Owns the
|
||||||
name repair the codec's refusal backstops, and normalizes each shipping record's
|
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.
|
`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:
|
- `bank_package` — framing and arithmetic composing the two above:
|
||||||
`encodePackage` (prefix bytes + layout + total size, stamping this build's
|
`encodePackage` (prefix bytes + layout + total size, stamping this build's
|
||||||
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
|
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
|
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
|
||||||
codec rejects only what makes the container itself incoherent (duplicate
|
codec rejects only what makes the container itself incoherent (duplicate
|
||||||
entry names, invalid names, a non-single-sample nested index).
|
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
|
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
|
||||||
version pair classifies `Readable`; for `TooNew` it stops at the semver —
|
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
|
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
|
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
|
table-free fix does not exist, and restricting names to ASCII would be
|
||||||
genuinely over-strict for non-English users. Left open knowingly.
|
genuinely over-strict for non-English users. Left open knowingly.
|
||||||
- **`duplicateName` is O(n²) over `entries` on the decode path** — pre-existing
|
- **`duplicateName` folds through a hash set, not a pairwise scan.** Under the
|
||||||
shape (the double loop is unchanged since `af35fc5`; only the comparator
|
`kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile
|
||||||
changed). Under the `kMaxManifestBytes` cap (64 MB) a minimal entry is
|
package can declare ~670k entries; the former double loop was ~2×10¹¹ pair
|
||||||
~100 bytes, so a hostile package can declare ~670k entries — ~2×10¹¹ pair
|
comparisons — a multi-minute hang on the decode path an import drives. The
|
||||||
comparisons, a multi-minute hang on import. It signals an error rather than
|
set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold
|
||||||
UB, so the hostile-input invariant above still holds, but it sits against
|
made explicit, so the equivalence rule still has one home (`lowerAscii`).
|
||||||
this module's "a forged header cannot demand gigabytes" posture. Forward
|
Do not reintroduce the pairwise scan.
|
||||||
obligation for `import_plan`: fold this into a sorted vector or hash set
|
|
||||||
when that track lands; not changed here.
|
|
||||||
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
|
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
|
||||||
entry cannot round-trip through the filesystem seam there (`appendPayload`
|
entry cannot round-trip through the filesystem seam there (`appendPayload`
|
||||||
refuses an empty payload — an empty buffer signals an upstream read failure,
|
refuses an empty payload — an empty buffer signals an upstream read failure,
|
||||||
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
|
not a real entry). `serializeManifest` refuses a zero-length `PackageEntry`
|
||||||
at encode so this layer never produces one; decode does not enforce it (a
|
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).
|
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.
|
||||||
|
|||||||
@@ -17,3 +17,10 @@ reasampler_pure_library(bank_package
|
|||||||
LINK PUBLIC package_format package_manifest PRIVATE app_version)
|
LINK PUBLIC package_format package_manifest PRIVATE app_version)
|
||||||
# app_version: the tests pin the stamped writer semver against stampVersion().
|
# app_version: the tests pin the stamped writer semver against stampVersion().
|
||||||
reasampler_test(bank_package LINK bank_package app_version)
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -103,6 +103,13 @@ bool sameEntryName(const std::string& a, const std::string& b) {
|
|||||||
return true;
|
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) {
|
bool isValidNestedSamplePath(const std::string& path) {
|
||||||
if (util::isAbsolutePath(path)) return false;
|
if (util::isAbsolutePath(path)) return false;
|
||||||
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
|
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ bool isValidEntryName(const std::string& name);
|
|||||||
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
|
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
|
||||||
bool sameEntryName(const std::string& a, const std::string& b);
|
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
|
// 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
|
// relativePath, which is bank-relative by design. Rejects every absolute form
|
||||||
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
|
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "core/package/package_manifest.h"
|
#include "core/package/package_manifest.h"
|
||||||
|
|
||||||
|
#include <unordered_set>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include "core/json/json.h"
|
#include "core/json/json.h"
|
||||||
@@ -14,11 +15,16 @@ using ObjWriter = json::Writer;
|
|||||||
|
|
||||||
// Shared by serializeManifest and deserializeManifest — see this directory's
|
// Shared by serializeManifest and deserializeManifest — see this directory's
|
||||||
// CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the
|
// 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) {
|
bool duplicateName(const std::vector<PackageEntry>& entries) {
|
||||||
for (std::size_t i = 0; i < entries.size(); ++i)
|
std::unordered_set<std::string> seen;
|
||||||
for (std::size_t j = i + 1; j < entries.size(); ++j)
|
seen.reserve(entries.size());
|
||||||
if (sameEntryName(entries[i].fileName, entries[j].fileName)) return true;
|
for (const auto& e : entries)
|
||||||
|
if (!seen.insert(entryNameKey(e.fileName)).second) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
|
Tiny, dependency-free pure helpers linked by both artifacts: whole-file byte
|
||||||
loading, unit-interval clamping, the absolute-path rejection test, and the
|
loading, unit-interval clamping, the absolute-path rejection test, the
|
||||||
per-segment envelope curve law.
|
per-segment envelope curve law, and the ASCII-whitespace fold test.
|
||||||
|
|
||||||
## Modules
|
## 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
|
before curves existed play unchanged, and what the knob law's centre detent exists to keep
|
||||||
reachable from the dial.
|
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.
|
- `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
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
The bindable action families routed through REAPER's `command_id`/`gaccel`/
|
The bindable action families routed through REAPER's `command_id`/`gaccel`/
|
||||||
`hookcommand` contract (Design View toggle actions, bank actions, the prune
|
`hookcommand` contract (Design View toggle actions, bank actions, the prune
|
||||||
action, and the shared registration plumbing/table), plus the three drag-out
|
action, the bank-package import action, and the shared registration
|
||||||
|
plumbing/table), plus the three drag-out
|
||||||
outcome shells (OS hand-off, instrument drop, arrange drop), plus the
|
outcome shells (OS hand-off, instrument drop, arrange drop), plus the
|
||||||
extension-side ingest-through-the-bank shell. This is
|
extension-side ingest-through-the-bank shell. This is
|
||||||
where user-facing REAPER actions and OS-level drag/drop live; the underlying
|
where user-facing REAPER actions and OS-level drag/drop live; the underlying
|
||||||
@@ -43,6 +44,7 @@ is owned by other directories and only skinned here.
|
|||||||
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
|
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
|
||||||
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
- `instrument_drop_win` — instrument-drop shell: `probeDropTarget` resolves a screen point to a track + a `ReaperSurface` (via the pure `wire::classifyReaperSurface`, whose token rules `core/wire/CLAUDE.md` owns), and the drop half adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
|
||||||
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
|
- `arrange_drop_win` — the drag-out gesture's arrange outcome: `arrangeTimeAtScreenX` (pointer column → time via `GetSet_ArrangeView2`'s one-pixel-span reading — inferred, not SDK-documented) and `performArrangeDrop` (snap the drop time, then one `InsertMedia` per capture on the pointer's track — assumed, not confirmed, to land end-to-end via REAPER's own cursor advance — in ONE undo block, counting only InsertMedia's reported successes, with the caller's track selection and edit cursor restored). The one timeline-placing shell here, per the invariant above; it never captures and never writes the bank.
|
||||||
|
- `package_import_action` — the bindable / bank-menu / file-drop skin over `shell/package`'s `importBankPackage`. Owns the **ledger gate**, which runs BEFORE the file picker (a refusal must not cost the user a file choice) and is keyed on the session's `LedgerStatus` alone — never on `PruneReport::blockedByTracking`, whose undecodable-`rsusage_*` arm governs deletion-time protection and would refuse an import that only writes birth records. Builds and shows every message the import produces, but the ledger-refusal body itself is `core/package::ledgerRefusalMessage` — a pure fold this TU only supplies the channel-correct namespace to — so the wording is assertable without a DAW. `doImportBankPackage`/`doImportBankPackageFile` return the minted bank id on a landed import (empty otherwise) so a caller can focus it; the verb itself is promptless.
|
||||||
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
|
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.** Surface (2)'s action is the one in this directory published into a NON-main action section (Media Explorer) as well as Main — two ids, one handler, two dispatch hooks; see root `CLAUDE.md` §"REAPER extension contract" for the mechanism.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
// package_import_action.cpp — see package_import_action.h for the contract.
|
||||||
|
// main.cpp owns the API pointers; this TU gets them extern.
|
||||||
|
|
||||||
|
#include "shell/actions/package_import_action.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/package/import_plan.h"
|
||||||
|
#include "core/package/package_format.h"
|
||||||
|
#include "core/version/app_version.h"
|
||||||
|
#include "shell/package/import_bank.h"
|
||||||
|
#include "shell/package/package_pickers.h"
|
||||||
|
#include "shell/persist/session.h"
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||||
|
#define REAPERAPI_WANT_ShowMessageBox
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char* kTitle = "ReaSampler: import bank package";
|
||||||
|
|
||||||
|
std::string quoted(const std::string& s) { return "\"" + s + "\""; }
|
||||||
|
|
||||||
|
// The message body itself is core/package::ledgerRefusalMessage — a pure
|
||||||
|
// (LedgerRefusal, namespace) -> string fold, testable without a DAW. This TU only
|
||||||
|
// supplies the channel-correct namespace and the console call.
|
||||||
|
void reportLedgerRefusal(package::LedgerRefusal refusal) {
|
||||||
|
ShowConsoleMsg(
|
||||||
|
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refusal a user can act on names all three: what the package needs, what this
|
||||||
|
// build reads, and which build wrote it. Any two of them leave them stuck.
|
||||||
|
void reportTooNew(const ImportBankResult& r) {
|
||||||
|
const bool knownWriter = !r.header.writerVersion.empty();
|
||||||
|
const std::string writer =
|
||||||
|
knownWriter ? "ReaSampler " + r.header.writerVersion : std::string("an unidentified build");
|
||||||
|
std::string msg =
|
||||||
|
"Cannot import this bank package.\n"
|
||||||
|
"It was written by " + writer + " and needs package format " +
|
||||||
|
std::to_string(r.header.minReaderVersion) + " or newer.\n"
|
||||||
|
"This build (" + version::appVersion() + ") reads package format " +
|
||||||
|
std::to_string(package::kPackageFormatVersion) + ".\n"
|
||||||
|
"Nothing was imported. ";
|
||||||
|
// "Install <writer> or newer" reads fine when writer is a real semver; it does not
|
||||||
|
// when writer is the "unidentified build" filler, so that case gets its own sentence.
|
||||||
|
msg += knownWriter ? "Install " + writer + " or newer and try again."
|
||||||
|
: "Install a newer version of ReaSampler and try again.";
|
||||||
|
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reportSuccess(const ImportBankResult& r) {
|
||||||
|
std::string detail = "ReaSampler import: imported " + std::to_string(r.landedCount) +
|
||||||
|
" sample(s) into a new bank: " + quoted(r.bankDisplayName);
|
||||||
|
if (r.bankNameAdjusted)
|
||||||
|
detail += " (a bank named " + quoted(r.seedBankName) +
|
||||||
|
" already exists in this project)";
|
||||||
|
detail += ".\n";
|
||||||
|
// Two distinct triggers (core/package::ImportPlan), reported as two counts rather
|
||||||
|
// than folded into one ambiguous "already taken, or not spelled right" line.
|
||||||
|
if (r.collisionRenameCount > 0) {
|
||||||
|
detail += " " + std::to_string(r.collisionRenameCount) +
|
||||||
|
" file(s) landed under a freshly minted name (the package's own name "
|
||||||
|
"was already taken in the bank folder). An existing bank file is "
|
||||||
|
"never overwritten.\n";
|
||||||
|
}
|
||||||
|
if (r.sanitizeRenameCount > 0) {
|
||||||
|
detail += " " + std::to_string(r.sanitizeRenameCount) +
|
||||||
|
" file(s) landed under a freshly minted name (not spelled the way "
|
||||||
|
"this bank spells a file).\n";
|
||||||
|
}
|
||||||
|
if (r.collapsedCount > 0) {
|
||||||
|
// "Already present" here can only mean a duplicate BY CONTENT inside this same
|
||||||
|
// package (Ε-F2: import never consults another bank's hashes) — deliberately
|
||||||
|
// reworded from bank-package.md:448's "already present" phrasing, which reads
|
||||||
|
// as "already in your project" and is misleading in this direction.
|
||||||
|
detail += " " + std::to_string(r.collapsedCount) +
|
||||||
|
" sample(s) duplicated another entry in this same package by content "
|
||||||
|
"and were written once.\n";
|
||||||
|
}
|
||||||
|
detail += "One undo removes the imported bank and its entries. It does NOT delete "
|
||||||
|
"the imported files -- they stay in the bank folder, referenced by "
|
||||||
|
"nothing, until a prune reclaims them.\n";
|
||||||
|
ShowConsoleMsg(detail.c_str());
|
||||||
|
|
||||||
|
// The console carries the copyable detail; the box makes the outcome unmissable.
|
||||||
|
const std::string summary = "Imported " + std::to_string(r.landedCount) +
|
||||||
|
" sample(s) into a new bank: " +
|
||||||
|
quoted(r.bankDisplayName) + ".";
|
||||||
|
ShowMessageBox(summary.c_str(), kTitle, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reportRollback(const RollbackResult& rollback, std::string& msg) {
|
||||||
|
if (rollback.failedCount > 0) {
|
||||||
|
msg += "\n" + std::to_string(rollback.failedCount) +
|
||||||
|
" partly-imported file(s) could not be removed and are still in the bank "
|
||||||
|
"folder. They are referenced by no bank; a prune will reclaim them.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void report(const ImportBankResult& r) {
|
||||||
|
switch (r.outcome) {
|
||||||
|
case ImportOutcome::Landed:
|
||||||
|
reportSuccess(r);
|
||||||
|
return;
|
||||||
|
case ImportOutcome::TooNew:
|
||||||
|
reportTooNew(r);
|
||||||
|
return;
|
||||||
|
case ImportOutcome::NoProject:
|
||||||
|
ShowMessageBox("Save the project before importing a bank package -- an "
|
||||||
|
"unsaved project has no bank folder to import into.",
|
||||||
|
kTitle, 0);
|
||||||
|
return;
|
||||||
|
case ImportOutcome::Unreadable:
|
||||||
|
ShowMessageBox("That file could not be opened. Nothing was imported.",
|
||||||
|
kTitle, 0);
|
||||||
|
return;
|
||||||
|
case ImportOutcome::Malformed:
|
||||||
|
// Distinct from TooNew on purpose: the recoveries are opposite -- one is
|
||||||
|
// "install a newer build", this one is "get an intact copy".
|
||||||
|
//
|
||||||
|
// bank-package.md:443 asks for a separate "This package is not well-formed"
|
||||||
|
// message when an entry name carries a separator / ".." / an absolute form.
|
||||||
|
// Not implemented: deserializeManifest returns one indistinguishable nullopt
|
||||||
|
// for that and for ordinary corruption, so it folds into this generic box.
|
||||||
|
// The binding spec (PLAN.md:2678) only requires Malformed != TooNew, which
|
||||||
|
// this still satisfies -- that product-doc row is knowingly left open, not
|
||||||
|
// silently missed.
|
||||||
|
ShowMessageBox("This file is not a readable bank package (corrupt or "
|
||||||
|
"truncated). Nothing was imported.",
|
||||||
|
kTitle, 0);
|
||||||
|
return;
|
||||||
|
case ImportOutcome::IntegrityFailed: {
|
||||||
|
std::string msg = "This bank package is damaged (entry " +
|
||||||
|
quoted(r.failedEntryName) +
|
||||||
|
" failed its integrity check). Nothing was imported.";
|
||||||
|
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case ImportOutcome::WriteFailed: {
|
||||||
|
std::string msg = "Import failed and was rolled back. Nothing was added.";
|
||||||
|
reportRollback(r.rollback, msg);
|
||||||
|
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case ImportOutcome::IndexRejected: {
|
||||||
|
std::string msg = "The bank index rejected the import. Nothing was added.";
|
||||||
|
reportRollback(r.rollback, msg);
|
||||||
|
ShowMessageBox(msg.c_str(), kTitle, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIRST, before the picker: making the user find and choose a file we have already
|
||||||
|
// decided to refuse is the wrong order.
|
||||||
|
bool ledgerPermits(ReaSamplerSession& session) {
|
||||||
|
const package::LedgerRefusal refusal =
|
||||||
|
package::importLedgerRefusal(session.ledgerStatus());
|
||||||
|
if (refusal == package::LedgerRefusal::None) return true;
|
||||||
|
reportLedgerRefusal(refusal);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string doImportBankPackage(ReaSamplerSession& session) {
|
||||||
|
if (!ledgerPermits(session)) return {};
|
||||||
|
std::string path;
|
||||||
|
if (!pickPackageForImport(path) || path.empty()) return {};
|
||||||
|
const ImportBankResult r = importBankPackage(session, path);
|
||||||
|
report(r);
|
||||||
|
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath) {
|
||||||
|
if (packageAbsPath.empty()) return {};
|
||||||
|
if (!ledgerPermits(session)) return {};
|
||||||
|
const ImportBankResult r = importBankPackage(session, packageAbsPath);
|
||||||
|
report(r);
|
||||||
|
return r.outcome == ImportOutcome::Landed ? r.bankId : std::string{};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
// package_import_action — the bindable/menu/drop skin over importBankPackage: the
|
||||||
|
// ledger gate (which runs BEFORE the picker, so a refusal never costs the user a file
|
||||||
|
// choice), the picker itself, and every message the import produces.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
class ReaSamplerSession;
|
||||||
|
|
||||||
|
// Gate, pick, import, report. The bound action and the panel's bank menu both call
|
||||||
|
// this. Returns the minted bank id on a landed import, "" otherwise (cancelled,
|
||||||
|
// refused, or failed) — a caller that wants to focus the new bank (mirroring
|
||||||
|
// doCreateBank) checks the return rather than reaching back into ImportBankResult.
|
||||||
|
std::string doImportBankPackage(ReaSamplerSession& session);
|
||||||
|
|
||||||
|
// Same, for a .rsbank already named by the user — the panel's file-drop route. The gate
|
||||||
|
// still runs first; only the picker is skipped. Same return contract as doImportBankPackage.
|
||||||
|
std::string doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -5,12 +5,15 @@
|
|||||||
The filesystem and dialog acts behind bank-package export/import: streaming package
|
The filesystem and dialog acts behind bank-package export/import: streaming package
|
||||||
file I/O plus the file-status and exclusive-create acts (`package_io`), the
|
file I/O plus the file-status and exclusive-create acts (`package_io`), the
|
||||||
UTF-8 path conversion every one of them goes through (`package_path`), the landed-file
|
UTF-8 path conversion every one of them goes through (`package_path`), the landed-file
|
||||||
journal and its rollback delete (`package_rollback`), the two file pickers
|
journal and its rollback delete (`package_rollback`), and the two file pickers
|
||||||
(`package_pickers`), and the promptless export verb (`export_bank`) — the import verb
|
(`package_pickers`). Those are bytes-only — the package format (magic, manifest, entry
|
||||||
does not live here yet. The package format itself (magic, manifest, entry layout)
|
layout) is `core/package`'s business. Beside them sit both promptless verbs:
|
||||||
stays `core/package`'s business. No REAPER project state is touched in this directory:
|
`export_bank` whole, and the import split so its decisions stay testable —
|
||||||
no ext-state read or write, no undo block, no generation bump — those belong to the
|
`import_landing` (REAPER-free) decides and writes, while `import_bank` owns the only
|
||||||
prompting skin (`shell/actions/package_export_action`), not this seam.
|
REAPER project state this directory touches (the ext-state persist, the undo block,
|
||||||
|
the generation bump). The export direction touches none of it: an export writes no ext
|
||||||
|
state, opens no undo point and never bumps the generation, and what prompting it needs
|
||||||
|
belongs to its skin (`shell/actions/package_export_action`), not this seam.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
@@ -68,7 +71,14 @@ prompting skin (`shell/actions/package_export_action`), not this seam.
|
|||||||
landed files with no index entry and a journal that now refuses to roll them
|
landed files with no index entry and a journal that now refuses to roll them
|
||||||
back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying
|
back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying
|
||||||
an armed journal without calling either does NOT roll it back — see
|
an armed journal without calling either does NOT roll it back — see
|
||||||
`LandedFileJournal`'s own doc comment.)
|
`LandedFileJournal`'s own doc comment.) `import_bank` honours it: it calls
|
||||||
|
`markIndexCommitted()` only after `persistBankOp` has returned.
|
||||||
|
- **Integrity is proven before the first byte lands, not undone after.**
|
||||||
|
`landPackage` hashes every declared payload against the manifest and only then
|
||||||
|
creates the bank folder, so a damaged package costs no rollback at all and cannot
|
||||||
|
leave debris behind a rollback that itself failed. The second read of each payload
|
||||||
|
is deliberate on a once-per-gesture path — do not fold it into one
|
||||||
|
hash-and-write pass.
|
||||||
- **Both pickers ride `GetUserFileName`** — mode 1 for import, mode 0 for export.
|
- **Both pickers ride `GetUserFileName`** — mode 1 for import, mode 0 for export.
|
||||||
There is no platform split and no fallback: `main.cpp` defines `REAPERAPI_IMPLEMENT`
|
There is no platform split and no fallback: `main.cpp` defines `REAPERAPI_IMPLEMENT`
|
||||||
without `REAPERAPI_MINIMAL` and aborts the extension load if any single name fails
|
without `REAPERAPI_MINIMAL` and aborts the extension load if any single name fails
|
||||||
@@ -79,8 +89,10 @@ prompting skin (`shell/actions/package_export_action`), not this seam.
|
|||||||
- `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point.
|
- `package_path` — header-only; the ONE UTF-8-narrow → `fs::path` conversion, so the encoding contract has a single enforcement point.
|
||||||
- `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
|
- `package_io` — every filesystem act the verbs need: `PayloadBuffer` (move-only payload + the `alive()` seam counter), `PackageFileWriter` (append-only temp+atomic-rename writer), `PackageFileReader` (seek-and-read one range per call, range-checked against the real file size), `readFilePayload` (one source file as one entry's payload), `fileStatus` (Present/Absent/Unreadable — export's refusal message must distinguish the last two, and an empty payload cannot), `writeFileExclusive` (exclusive create + write, self-cleaning on a partial write), and `listFolderFileNames` (bare UTF-8 names, sorted, non-recursive, non-throwing). REAPER-free; tested without a DAW.
|
||||||
- `package_rollback` — `LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
|
- `package_rollback` — `LandedFileJournal`: `writeLandedFile` (exclusive-create land, path resolved absolute, recorded on success only), `markIndexCommitted` (disarms the journal), and `rollback` (deletes exactly the recorded set, hard unlink, tolerating a vanished file; refuses once disarmed). REAPER-free; tested without a DAW.
|
||||||
- `package_pickers` — `pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`; `pickPackageSavePath` also reports whether it appended `.rsbank` (`outAppended`), the signal `package_export_action` uses to skip a redundant overwrite confirm. `pickPackageForImport` stays compile-only until the import verb lands; neither picker can be exercised in a unit test.
|
- `package_pickers` — `pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`; `pickPackageSavePath` also reports whether it appended `.rsbank` (`outAppended`), the signal `package_export_action` uses to skip a redundant overwrite confirm. Neither picker can be exercised in a unit test.
|
||||||
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const**: `saveToActiveProject`, `bumpBankGeneration` and `writeAssignmentRequest` are the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (`pruneReclaim`, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.
|
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const**: `saveToActiveProject`, `bumpBankGeneration` and `writeAssignmentRequest` are the session's only non-const acts, so a const session cannot reach them and "an export writes no ext state, opens no undo point and never bumps the generation" holds by the type rather than by memory (`pruneReclaim`, the sole file-deletion path, is const too and sits outside this claim). Reads the session through inline accessors only, which is why its tests link and run without a DAW.
|
||||||
|
- `import_landing` — the import's two halves that decide anything: `landPackage` (decode, plan, verify EVERY payload's digest, then land through the journal) and `applyImportedBank` (the new bank's entries plus a birth record per landed file, in one straight-line block). REAPER-free deliberately — all-or-nothing, integrity and birth-record behaviour are assertable without a DAW.
|
||||||
|
- `import_bank` — the promptless import verb over a live `ReaSamplerSession`: the project directory, the minted bank id, the `recordCreated` writer, and the one undo-batched persist. REAPER-facing, so it compiles into the extension module rather than into a library with a test target.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# The filesystem + dialog seam for bank packages. package_io / package_rollback are
|
# The filesystem + dialog seam for bank packages. package_io / package_rollback are
|
||||||
# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and
|
# REAPER-free (standard filesystem only), so the pure-library/test helpers fit and
|
||||||
# their tests run without a DAW. export_bank, the promptless export verb, lives here
|
# their tests run without a DAW. Both verbs live here too: export_bank whole, and the
|
||||||
# too for the same reason (ReaSamplerSession's inline accessors keep it REAPER-free);
|
# import's REAPER-free half (import_landing) — the import's REAPER-facing half
|
||||||
# the import verb does not live here yet.
|
# (import_bank.cpp) compiles into the extension module instead.
|
||||||
|
|
||||||
reasampler_pure_library(package_io SOURCES package_io.cpp)
|
reasampler_pure_library(package_io SOURCES package_io.cpp)
|
||||||
reasampler_test(package_io LINK package_io)
|
reasampler_test(package_io LINK package_io)
|
||||||
@@ -25,6 +25,14 @@ reasampler_test(export_bank
|
|||||||
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
|
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
|
||||||
tracking_authority prune_reconcile app_version capture_paths)
|
tracking_authority prune_reconcile app_version capture_paths)
|
||||||
|
|
||||||
|
# The import's decisions and its file half, both REAPER-free, so all-or-nothing,
|
||||||
|
# integrity and birth-record behaviour are assertable without a DAW. The REAPER-facing
|
||||||
|
# verb over them (import_bank.cpp) compiles into the extension module instead.
|
||||||
|
reasampler_pure_library(import_landing
|
||||||
|
SOURCES import_landing.cpp
|
||||||
|
LINK PUBLIC import_plan package_rollback bank_book PRIVATE bank_package wav_codec)
|
||||||
|
reasampler_test(import_landing LINK import_landing bank_package app_version wav_codec origin_ledger)
|
||||||
|
|
||||||
# The pickers call the REAPER API, so no test target can exercise them; declared as a
|
# The pickers call the REAPER API, so no test target can exercise them; declared as a
|
||||||
# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
|
# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
|
||||||
add_library(package_pickers STATIC package_pickers.cpp)
|
add_library(package_pickers STATIC package_pickers.cpp)
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// import_bank.cpp — see import_bank.h for the contract. The REAPER-facing half of the
|
||||||
|
// import: the project directory, the minted bank id, the birth records, and the one
|
||||||
|
// undo-batched persist. Every decision it makes is in import_landing / import_plan.
|
||||||
|
//
|
||||||
|
// main.cpp owns the API pointers; this TU gets them extern. DAW-verified, not unit tested.
|
||||||
|
|
||||||
|
#include "shell/package/import_bank.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/capture/capture_paths.h" // projectDirOfRpp
|
||||||
|
#include "shell/bank_ops/bank_ops.h" // persistBankOp — one bank op is one Ctrl-Z
|
||||||
|
#include "shell/persist/session.h"
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
|
#define REAPERAPI_WANT_genGuid
|
||||||
|
#define REAPERAPI_WANT_guidToString
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string activeProjectDir() {
|
||||||
|
std::vector<char> buf(4096, '\0');
|
||||||
|
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
|
||||||
|
return capture::projectDirOfRpp(std::string(buf.data()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The model mints no ids (it stays pure and deterministic), so the shell does — the
|
||||||
|
// same GUID pair bankOpCreate uses.
|
||||||
|
std::string mintBankId() {
|
||||||
|
GUID g{};
|
||||||
|
genGuid(&g);
|
||||||
|
char buf[64] = {0}; // guidToString needs >=64 chars (SDK contract)
|
||||||
|
guidToString(&g, buf);
|
||||||
|
return std::string(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fillPlanCounts(ImportBankResult& out, const package::ImportPlan& plan) {
|
||||||
|
out.bankDisplayName = plan.bankDisplayName;
|
||||||
|
out.seedBankName = plan.seedBankName;
|
||||||
|
out.bankNameAdjusted = plan.bankNameAdjusted;
|
||||||
|
out.landedCount = plan.landCount;
|
||||||
|
out.collisionRenameCount = plan.collisionRenameCount;
|
||||||
|
out.sanitizeRenameCount = plan.sanitizeRenameCount;
|
||||||
|
out.collapsedCount = plan.collapseCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ImportBankResult importBankPackage(ReaSamplerSession& session,
|
||||||
|
const std::string& packageAbsPath) {
|
||||||
|
ImportBankResult out;
|
||||||
|
|
||||||
|
// A per-import disambiguator, the same shape capture and ingest file under.
|
||||||
|
const std::string uniqueTag =
|
||||||
|
std::to_string(static_cast<std::int64_t>(std::time(nullptr)));
|
||||||
|
|
||||||
|
LandedFileJournal journal;
|
||||||
|
const ImportLanding landing = landPackage(packageAbsPath, activeProjectDir(),
|
||||||
|
session.book(), uniqueTag, journal);
|
||||||
|
out.outcome = landing.outcome;
|
||||||
|
out.header = landing.header;
|
||||||
|
out.failedEntryName = landing.failedEntryName;
|
||||||
|
out.rollback = landing.rollback;
|
||||||
|
fillPlanCounts(out, landing.plan);
|
||||||
|
if (landing.outcome != ImportOutcome::Landed) return out;
|
||||||
|
|
||||||
|
const std::string bankId = mintBankId();
|
||||||
|
const bool applied = applyImportedBank(
|
||||||
|
session.book(), bankId, landing.plan,
|
||||||
|
[&session](const model::Sample& s) {
|
||||||
|
session.recordCreated(s, tracking::OriginKind::PackageImport);
|
||||||
|
});
|
||||||
|
if (!applied) {
|
||||||
|
out.outcome = ImportOutcome::IndexRejected;
|
||||||
|
out.rollback = journal.rollback();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out.bankId = bankId;
|
||||||
|
|
||||||
|
// Generation bump + persist ride inside one undo block, so a Ctrl-Z takes the whole
|
||||||
|
// import back out of the index. It does NOT un-write the files — the summary says so.
|
||||||
|
persistBankOp(session, "ReaSampler: import bank package", /*bumpGeneration=*/true);
|
||||||
|
// Only now: the files are referenced, so prune's self-cleanup carve-out no longer
|
||||||
|
// covers them (see package_rollback.h).
|
||||||
|
journal.markIndexCommitted();
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
// shell/package/import_bank — the promptless import verb: one package becomes one NEW
|
||||||
|
// bank in the live session, completely or not at all. No prompts, no message boxes, no
|
||||||
|
// picker — it reports and the action skin (shell/actions/package_import_action) speaks.
|
||||||
|
// The ledger gate is the skin's, because it must refuse BEFORE a file is even chosen.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/package/import_plan.h"
|
||||||
|
#include "shell/package/import_landing.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
class ReaSamplerSession;
|
||||||
|
|
||||||
|
struct ImportBankResult {
|
||||||
|
ImportOutcome outcome = ImportOutcome::Unreadable;
|
||||||
|
package::PackageHeader header; // TooNew names the writer's build from here
|
||||||
|
|
||||||
|
std::string bankId; // the minted id — meaningful only when outcome == Landed
|
||||||
|
std::string bankDisplayName; // the bank actually created
|
||||||
|
std::string seedBankName; // what the package asked to be called
|
||||||
|
bool bankNameAdjusted = false;
|
||||||
|
|
||||||
|
int landedCount = 0;
|
||||||
|
int collisionRenameCount = 0; // renamed: the package's own name was already taken
|
||||||
|
int sanitizeRenameCount = 0; // renamed: not spelled the way this tool spells a bank file
|
||||||
|
int collapsedCount = 0;
|
||||||
|
|
||||||
|
std::string failedEntryName;
|
||||||
|
RollbackResult rollback;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lands `packageAbsPath` as a new bank in `session`, in ONE undo point, bumping the
|
||||||
|
// bank generation so live instances reload. Places no timeline item. On any failure
|
||||||
|
// nothing remains on disk and the book is untouched.
|
||||||
|
ImportBankResult importBankPackage(ReaSamplerSession& session,
|
||||||
|
const std::string& packageAbsPath);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// import_landing.cpp — see import_landing.h for the contract. REAPER-free: standard
|
||||||
|
// filesystem only, so every property this file decides is unit-testable.
|
||||||
|
|
||||||
|
#include "shell/package/import_landing.h"
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <system_error>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/capture/wav_codec.h" // hashBytes — the digest the manifest records
|
||||||
|
#include "core/package/bank_package.h"
|
||||||
|
#include "shell/package/package_io.h"
|
||||||
|
#include "shell/package/package_path.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using package::EntryAction;
|
||||||
|
using package::PackageEntrySpan;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
ImportLanding refusal(ImportOutcome outcome, const package::PackageHeader& header) {
|
||||||
|
ImportLanding out;
|
||||||
|
out.outcome = outcome;
|
||||||
|
out.header = header;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the package head incrementally: requiredPrefixSize may grow its answer as
|
||||||
|
// fields arrive, so ask, read to the count, ask again. False means these bytes can
|
||||||
|
// never frame a package, or the file is shorter than its own header claims.
|
||||||
|
bool readPrefix(PackageFileReader& reader, std::vector<std::uint8_t>& prefix) {
|
||||||
|
for (;;) {
|
||||||
|
const auto need = package::requiredPrefixSize(prefix);
|
||||||
|
if (!need) return false;
|
||||||
|
if (prefix.size() >= *need) return true;
|
||||||
|
PayloadBuffer head = reader.readRange(0, *need);
|
||||||
|
if (head.size() != *need) return false;
|
||||||
|
prefix.assign(head.data(), head.data() + head.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ImportLanding landPackage(const std::string& packageAbsPath,
|
||||||
|
const std::string& projectDir,
|
||||||
|
const BankBook& destination,
|
||||||
|
const std::string& uniqueTag,
|
||||||
|
LandedFileJournal& journal) {
|
||||||
|
const package::PackageHeader noHeader;
|
||||||
|
if (projectDir.empty()) return refusal(ImportOutcome::NoProject, noHeader);
|
||||||
|
|
||||||
|
PackageFileReader reader(packageAbsPath);
|
||||||
|
if (!reader.ok()) return refusal(ImportOutcome::Unreadable, noHeader);
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> prefix;
|
||||||
|
if (!readPrefix(reader, prefix)) return refusal(ImportOutcome::Malformed, noHeader);
|
||||||
|
|
||||||
|
const package::DecodedPackage dec = package::decodePackage(prefix, reader.fileSize());
|
||||||
|
if (dec.status == package::PackageReadability::TooNew)
|
||||||
|
return refusal(ImportOutcome::TooNew, dec.header);
|
||||||
|
if (dec.status != package::PackageReadability::Readable)
|
||||||
|
return refusal(ImportOutcome::Malformed, dec.header);
|
||||||
|
|
||||||
|
const std::string bankDir = package::bankFolderDir(projectDir);
|
||||||
|
|
||||||
|
ImportLanding out;
|
||||||
|
out.header = dec.header;
|
||||||
|
out.plan = package::planImport(dec.manifest, destination, projectDir,
|
||||||
|
listFolderFileNames(bankDir), uniqueTag);
|
||||||
|
|
||||||
|
// Integrity first, over EVERY declared entry — including one the plan collapses,
|
||||||
|
// since a package that fails its own digest is refused whole rather than partly
|
||||||
|
// trusted. Nothing is on disk yet, so a failure here needs no rollback.
|
||||||
|
for (std::size_t i = 0; i < dec.layout.size(); ++i) {
|
||||||
|
const PackageEntrySpan& span = dec.layout[i];
|
||||||
|
// The format refuses a zero-length entry on encode; one arriving anyway cannot
|
||||||
|
// be told from a failed read at this seam, so it is not well-formed input.
|
||||||
|
if (span.length == 0) return refusal(ImportOutcome::Malformed, dec.header);
|
||||||
|
|
||||||
|
PayloadBuffer payload = reader.readRange(span.offset, span.length);
|
||||||
|
if (payload.size() != span.length) {
|
||||||
|
out.outcome = ImportOutcome::IntegrityFailed;
|
||||||
|
out.failedEntryName = span.name;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (capture::hashBytes(payload.data(), payload.size()) !=
|
||||||
|
dec.manifest.entries[i].byteHash) {
|
||||||
|
out.outcome = ImportOutcome::IntegrityFailed;
|
||||||
|
out.failedEntryName = span.name;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::error_code ec;
|
||||||
|
fs::create_directories(utf8Path(bankDir), ec); // idempotent; the write reports failure
|
||||||
|
|
||||||
|
for (const package::PlannedEntry& e : out.plan.entries) {
|
||||||
|
if (e.action != EntryAction::Land) continue;
|
||||||
|
const PackageEntrySpan& span = dec.layout[e.manifestIndex];
|
||||||
|
PayloadBuffer payload = reader.readRange(span.offset, span.length);
|
||||||
|
if (payload.size() == span.length &&
|
||||||
|
journal.writeLandedFile(bankDir + "/" + e.destFileName, payload)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.outcome = ImportOutcome::WriteFailed;
|
||||||
|
out.failedEntryName = e.destFileName;
|
||||||
|
out.rollback = journal.rollback();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.outcome = ImportOutcome::Landed;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool applyImportedBank(BankBook& book, const std::string& bankId,
|
||||||
|
const package::ImportPlan& plan, const RecordBirth& recordBirth) {
|
||||||
|
// An empty std::function throws std::bad_function_call on invoke; every real caller
|
||||||
|
// supplies one, so an empty one here is a caller bug, not a runtime condition to
|
||||||
|
// recover from — enforce the contract rather than let it surface as an uncaught
|
||||||
|
// exception out of an extension action.
|
||||||
|
assert(recordBirth && "applyImportedBank: RecordBirth must not be empty");
|
||||||
|
if (!book.createBank(bankId, plan.bankDisplayName)) return false;
|
||||||
|
|
||||||
|
BankModel* index = book.index(bankId);
|
||||||
|
for (const package::PlannedEntry& e : plan.entries) {
|
||||||
|
if (e.action != EntryAction::Land) continue;
|
||||||
|
const AddResult added = index->add(e.sample);
|
||||||
|
// planImport already deduped Land entries by hash against an empty destination
|
||||||
|
// bank (this same freshly-created one), so a Collapsed add here would mean the
|
||||||
|
// plan and the book disagree — that would silently undercount reportSuccess's
|
||||||
|
// landedCount rather than fail loudly.
|
||||||
|
assert(added == AddResult::Added && "planImport's Land entries must not collapse");
|
||||||
|
(void)added;
|
||||||
|
// Unconditional on the add's outcome: the file exists either way, and an
|
||||||
|
// unrecorded file is permanently unreclaimable.
|
||||||
|
recordBirth(e.sample);
|
||||||
|
}
|
||||||
|
|
||||||
|
book.bank(bankId)->slots = plan.slots;
|
||||||
|
book.reconcileSlots();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#pragma once
|
||||||
|
// shell/package/import_landing — the import's filesystem half and its index half,
|
||||||
|
// both REAPER-free so the all-or-nothing, integrity and birth-record properties are
|
||||||
|
// assertable without a DAW. The verb that drives them against a live session is
|
||||||
|
// import_bank; the REAPER-facing reporting is shell/actions/package_import_action.
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "core/model/bank_book.h"
|
||||||
|
#include "core/model/bank_model.h"
|
||||||
|
#include "core/package/import_plan.h"
|
||||||
|
#include "core/package/package_format.h"
|
||||||
|
#include "shell/package/package_rollback.h"
|
||||||
|
|
||||||
|
namespace reasampler {
|
||||||
|
|
||||||
|
// How a landing ended. Every value but Landed means NOTHING is on disk and NO index was
|
||||||
|
// touched. NoProject/Unreadable/Malformed/TooNew refuse before a byte is written.
|
||||||
|
// IntegrityFailed also refuses before any write — the full-package digest verification
|
||||||
|
// runs to completion first (landPackage) — so it needs no rollback either. WriteFailed
|
||||||
|
// is the only outcome that actually wrote and then rolled back. IndexRejected is never
|
||||||
|
// returned by landPackage/this struct — it is import_bank's own outcome, minted after a
|
||||||
|
// successful landing when the book itself refuses the create.
|
||||||
|
enum class ImportOutcome {
|
||||||
|
Landed,
|
||||||
|
NoProject, // unsaved project: there is no bank folder to land into
|
||||||
|
Unreadable, // the package file could not be opened
|
||||||
|
Malformed, // not a well-formed RSBK: corrupt, truncated, or trailing garbage
|
||||||
|
TooNew, // minReaderVersion above this build's ladder
|
||||||
|
IntegrityFailed, // an entry's payload did not match its recorded digest; pre-write refusal
|
||||||
|
WriteFailed, // a write failed partway; the landed files were rolled back
|
||||||
|
IndexRejected, // never set here — see the comment above; import_bank's outcome only
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ImportLanding {
|
||||||
|
ImportOutcome outcome = ImportOutcome::Unreadable;
|
||||||
|
// Meaningful from the moment the header parsed — a TooNew refusal names the
|
||||||
|
// writer's build, which is the only part of that message a user can act on.
|
||||||
|
package::PackageHeader header;
|
||||||
|
package::ImportPlan plan;
|
||||||
|
std::string failedEntryName; // IntegrityFailed / WriteFailed
|
||||||
|
RollbackResult rollback; // WriteFailed only — IntegrityFailed leaves it default
|
||||||
|
};
|
||||||
|
|
||||||
|
// Streams `packageAbsPath` into the project's bank folder: decode, plan, verify EVERY
|
||||||
|
// payload's digest, then land. Verification runs to completion before the first write,
|
||||||
|
// so a damaged package costs no rollback at all. Mutates no index and holds at most
|
||||||
|
// one payload at a time. `journal` is left armed on success — the caller applies the
|
||||||
|
// plan to the book and only then disarms it.
|
||||||
|
ImportLanding landPackage(const std::string& packageAbsPath,
|
||||||
|
const std::string& projectDir,
|
||||||
|
const BankBook& destination,
|
||||||
|
const std::string& uniqueTag,
|
||||||
|
LandedFileJournal& journal);
|
||||||
|
|
||||||
|
// Called for every landed file, in the same straight-line block as its bank add —
|
||||||
|
// core/tracking/CLAUDE.md's no-silent-gaps invariant, kept structural by passing the
|
||||||
|
// writer in rather than letting a caller add first and record later.
|
||||||
|
using RecordBirth = std::function<void(const model::Sample&)>;
|
||||||
|
|
||||||
|
// Adds the plan's landed entries to a NEW bank under `bankId`. False (no mutation)
|
||||||
|
// only if the book refuses the create — the name was minted free against this same
|
||||||
|
// book, so that means the book changed underneath the plan.
|
||||||
|
bool applyImportedBank(BankBook& book, const std::string& bankId,
|
||||||
|
const package::ImportPlan& plan, const RecordBirth& recordBirth);
|
||||||
|
|
||||||
|
} // namespace reasampler
|
||||||
@@ -48,7 +48,7 @@ live in `shell/bank_ops`, a sibling directory, not here.
|
|||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU.
|
- `bank_panel` (`shell/panel/`: `panel_window` / `panel_layout` / `panel_render` / `panel_input` / `panel_drag` / `panel_thumbnails` / `panel_audition` / `panel_bank_ops`, sharing state via `panel_state.h` — Q-W2 split of the former god-module into eight TUs) — docked LICE-drawn grid with three-zone layout: top toolbar (Capture → Maintenance → Placement via `action_bar`, short labels, More (⋯) overflow menu via `overflow_menu`), bottom toolbar (four opposite-mode tag buttons + Show Both), and footer (`[Arrange|Design]` toggle, Tail button, Prune via `footer_bar`). Grid renders in sparse slot order with gap cells, drop dispatch, metadata overlay, and selection via `accent/tertiary` purple border. Draws through the L1 kit by palette role; OS drag-out via `drag_out` + `drag_out_win`. `panel_window` owns the SWELL dialog lifecycle + dialog proc + drop-target opt-in; `panel_layout` the toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read); `panel_render` the WM_PAINT draw; `panel_input` click/wheel/keyboard routing + the new-content auto-tag timer; `panel_drag` the hover + card-drag state machine + drop dispatch; `panel_thumbnails` the PCM→envelope thumbnail cache + the bank-change fingerprint pass; `panel_audition` the preview-playback engine; `panel_bank_ops` the menu/prompt UX skin over the promptless `shell/bank_ops` verbs. `draw_kit` (shared with the VST3 editor) stays a separate TU.
|
||||||
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in.
|
- `panel_window` — SWELL dialog lifecycle + dialog proc + drop-target opt-in. The drop splits by extension: a `.rsbank` is a whole bank and routes to the package-import action (one NEW bank each), everything else keeps the audio-ingest route.
|
||||||
- `panel_layout` — toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read).
|
- `panel_layout` — toolbar/footer/menu rects + vertical-split geometry (the one geometry source both paint and hit-test read).
|
||||||
- `panel_render` — the WM_PAINT draw.
|
- `panel_render` — the WM_PAINT draw.
|
||||||
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
|
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "shell/panel/panel_bank_ops.h"
|
#include "shell/panel/panel_bank_ops.h"
|
||||||
|
|
||||||
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
|
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
|
||||||
|
#include "shell/actions/package_import_action.h" // doImportBankPackage — the menu's import row
|
||||||
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
|
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
|
||||||
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
|
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ enum : unsigned int {
|
|||||||
kMenuCreate,
|
kMenuCreate,
|
||||||
kMenuExport, // export this bank as a .rsbank package
|
kMenuExport, // export this bank as a .rsbank package
|
||||||
kMenuRemove, // remove selected sample(s) from the source bank
|
kMenuRemove, // remove selected sample(s) from the source bank
|
||||||
|
kMenuImportPackage, // land a .rsbank as a NEW bank (never merges into this one)
|
||||||
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
||||||
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
||||||
};
|
};
|
||||||
@@ -270,6 +272,7 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
|||||||
menuAppend(menu, kMenuExport, "Export as package...");
|
menuAppend(menu, kMenuExport, "Export as package...");
|
||||||
menuSeparator(menu);
|
menuSeparator(menu);
|
||||||
menuAppend(menu, kMenuCreate, "New bank...");
|
menuAppend(menu, kMenuCreate, "New bank...");
|
||||||
|
menuAppend(menu, kMenuImportPackage, "Import bank package...");
|
||||||
|
|
||||||
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
||||||
g_panel.hwnd, nullptr);
|
g_panel.hwnd, nullptr);
|
||||||
@@ -282,6 +285,18 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
|||||||
case kMenuDelete: doDeleteBank(bankId); break;
|
case kMenuDelete: doDeleteBank(bankId); break;
|
||||||
case kMenuExport: doBankPackageExport(*g_panel.session, bankId); break;
|
case kMenuExport: doBankPackageExport(*g_panel.session, bankId); break;
|
||||||
case kMenuCreate: doCreateBank(); break;
|
case kMenuCreate: doCreateBank(); break;
|
||||||
|
// Always a NEW bank, never a merge into the right-clicked one — the row sits
|
||||||
|
// here because this is the panel's bank menu, not because it targets this bank.
|
||||||
|
case kMenuImportPackage:
|
||||||
|
if (g_panel.session) {
|
||||||
|
const std::string id = doImportBankPackage(*g_panel.session);
|
||||||
|
if (!id.empty()) { // landed — show the freshly-imported bank
|
||||||
|
g_panel.shownBankId = id;
|
||||||
|
g_panel.focusedRegion = Region::Banks;
|
||||||
|
invalidatePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
default: break;
|
default: break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
|
|
||||||
#include "shell/panel/draw_kit.h"
|
#include "shell/panel/draw_kit.h"
|
||||||
#include "shell/actions/ingest.h"
|
#include "shell/actions/ingest.h"
|
||||||
|
#include "shell/actions/package_import_action.h"
|
||||||
|
#include "core/package/import_plan.h"
|
||||||
|
#include "core/version/app_version.h"
|
||||||
|
#include "shell/persist/session.h" // ReaSamplerSession::ledgerStatus() — panel_state.h only forward-declares it
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||||
@@ -28,6 +32,7 @@
|
|||||||
#define REAPERAPI_WANT_DockWindowActivate
|
#define REAPERAPI_WANT_DockWindowActivate
|
||||||
#define REAPERAPI_WANT_DockWindowRemove
|
#define REAPERAPI_WANT_DockWindowRemove
|
||||||
#define REAPERAPI_WANT_GetMainHwnd
|
#define REAPERAPI_WANT_GetMainHwnd
|
||||||
|
#define REAPERAPI_WANT_ShowConsoleMsg
|
||||||
#include "reaper_plugin_functions.h"
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
// main.cpp owns the module instance handle.
|
// main.cpp owns the module instance handle.
|
||||||
@@ -40,12 +45,24 @@ PanelState g_panel;
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
|
bool isPackagePath(const std::string& path) {
|
||||||
|
static const std::string kExt = ".rsbank";
|
||||||
|
if (path.size() <= kExt.size()) return false;
|
||||||
|
std::string tail = path.substr(path.size() - kExt.size());
|
||||||
|
for (char& c : tail)
|
||||||
|
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
|
||||||
|
return tail == kExt;
|
||||||
|
}
|
||||||
|
|
||||||
// DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; each path is then
|
// DragQueryFile(hDrop, 0xFFFFFFFF, ...) returns the file count; each path is then
|
||||||
// queried by index (length first, excludes NUL, then a sized buffer). DragFinish
|
// queried by index (length first, excludes NUL, then a sized buffer). DragFinish
|
||||||
// always frees the shell-allocated drop buffer. Multi-file drop imports all into
|
// always frees the shell-allocated drop buffer. A .rsbank is a whole bank, not audio,
|
||||||
// the active bank (bank-fill only — no assignment to any live instance).
|
// so it routes to the import verb (one new bank each); everything else keeps the
|
||||||
|
// existing ingest route — multi-file drop imports all into the active bank (bank-fill
|
||||||
|
// only, no assignment to any live instance).
|
||||||
void handleDropFiles(HDROP hDrop) {
|
void handleDropFiles(HDROP hDrop) {
|
||||||
std::vector<std::string> paths;
|
std::vector<std::string> paths;
|
||||||
|
std::vector<std::string> packages;
|
||||||
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
||||||
paths.reserve(count);
|
paths.reserve(count);
|
||||||
for (UINT i = 0; i < count; ++i) {
|
for (UINT i = 0; i < count; ++i) {
|
||||||
@@ -54,9 +71,26 @@ void handleDropFiles(HDROP hDrop) {
|
|||||||
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
||||||
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
|
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
|
||||||
std::string p(buf.data());
|
std::string p(buf.data());
|
||||||
if (!p.empty()) paths.push_back(std::move(p));
|
if (p.empty()) continue;
|
||||||
|
if (isPackagePath(p)) packages.push_back(std::move(p));
|
||||||
|
else paths.push_back(std::move(p));
|
||||||
}
|
}
|
||||||
DragFinish(hDrop);
|
DragFinish(hDrop);
|
||||||
|
if (g_panel.session && !packages.empty()) {
|
||||||
|
// One refusal block for the whole drop, not one per dropped .rsbank: the gate
|
||||||
|
// decision is the same for all N (session state does not change mid-drop), so
|
||||||
|
// checking it here first avoids doImportBankPackageFile's own per-file gate
|
||||||
|
// check printing the identical console block N times.
|
||||||
|
const package::LedgerRefusal refusal =
|
||||||
|
package::importLedgerRefusal(g_panel.session->ledgerStatus());
|
||||||
|
if (refusal != package::LedgerRefusal::None) {
|
||||||
|
ShowConsoleMsg(
|
||||||
|
package::ledgerRefusalMessage(refusal, version::extStateNamespace()).c_str());
|
||||||
|
} else {
|
||||||
|
for (const std::string& pkg : packages)
|
||||||
|
doImportBankPackageFile(*g_panel.session, pkg);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!paths.empty()) ingestDroppedFiles(paths);
|
if (!paths.empty()) ingestDroppedFiles(paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ public:
|
|||||||
// treatment.
|
// treatment.
|
||||||
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
|
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
|
||||||
|
|
||||||
|
// Whether a birth record can be written at all this session — the status WITHOUT
|
||||||
|
// the records. That is not a hole in the pairing rule above: the rule exists so an
|
||||||
|
// absent record is never read as a definite answer, and this exposes strictly less
|
||||||
|
// than the pair. The package import gates on it before it opens a file picker.
|
||||||
|
//
|
||||||
|
// A tradeoff, not the only route: `pruneDryRun()` already exposes the same degraded
|
||||||
|
// pair via `PruneReport::ledgerUnreadable`/`ledgerFutureVersion`, with no new
|
||||||
|
// accessor needed. Rejected because that route is genuinely worse for a gate: it
|
||||||
|
// drags a full bank-folder enumeration and every live instance's FX scan onto a
|
||||||
|
// check that only needs to know "can I write a record", and it shapes an import
|
||||||
|
// decision as an answer borrowed from prune's report rather than the session's own.
|
||||||
|
tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; }
|
||||||
|
|
||||||
// The version that last wrote the active project: PreVersioning (no
|
// The version that last wrote the active project: PreVersioning (no
|
||||||
// stamp), Unknown (malformed), or Stamped.
|
// stamp), Unknown (malformed), or Stamped.
|
||||||
const version::WritingVersion& writingVersion() const { return writingVersion_; }
|
const version::WritingVersion& writingVersion() const { return writingVersion_; }
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Standalone tests for shell/package/import_landing — no REAPER, no framework, real
|
||||||
|
// package bytes on a real filesystem. Packages are FRAMED BY HAND (not by
|
||||||
|
// encodePackage) so the version-ladder suites can dial formatVersion and
|
||||||
|
// minReaderVersion independently, and so an encode-side regression cannot hide the
|
||||||
|
// import's behaviour from itself.
|
||||||
|
|
||||||
|
#include "../src/shell/package/import_landing.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../src/core/capture/wav_codec.h"
|
||||||
|
#include "../src/core/package/bank_package.h"
|
||||||
|
#include "../src/core/tracking/origin_ledger.h"
|
||||||
|
#include "../src/core/version/app_version.h"
|
||||||
|
#include "../src/shell/package/package_path.h"
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::package;
|
||||||
|
using reasampler::model::Sample;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static const char* kTag = "1754000000";
|
||||||
|
static const char* kBankId = "import-test-bank";
|
||||||
|
|
||||||
|
// --- scratch project ---------------------------------------------------------
|
||||||
|
|
||||||
|
// One project directory per suite, torn down after, so no suite can observe another's
|
||||||
|
// bank folder in listFolderFileNames.
|
||||||
|
class Scratch {
|
||||||
|
public:
|
||||||
|
explicit Scratch(const std::string& name)
|
||||||
|
: dir_(pathToUtf8(fs::current_path() / utf8Path("import_scratch_" + name))) {
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove_all(utf8Path(dir_), ec);
|
||||||
|
fs::create_directories(utf8Path(dir_), ec);
|
||||||
|
}
|
||||||
|
~Scratch() {
|
||||||
|
std::error_code ec;
|
||||||
|
fs::remove_all(utf8Path(dir_), ec);
|
||||||
|
}
|
||||||
|
const std::string& projectDir() const { return dir_; }
|
||||||
|
std::string bankDir() const { return bankFolderDir(dir_); }
|
||||||
|
std::string packagePath() const { return dir_ + "/bank.rsbank"; }
|
||||||
|
|
||||||
|
std::vector<std::string> bankFiles() const {
|
||||||
|
std::vector<std::string> out;
|
||||||
|
std::error_code ec;
|
||||||
|
for (const auto& e : fs::directory_iterator(utf8Path(bankDir()), ec))
|
||||||
|
if (e.is_regular_file(ec)) out.push_back(pathToUtf8(e.path().filename()));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string dir_;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void writeBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
|
||||||
|
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
|
||||||
|
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||||
|
static_cast<std::streamsize>(bytes.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<std::uint8_t> readBytes(const std::string& path) {
|
||||||
|
std::ifstream f(utf8Path(path), std::ios::binary);
|
||||||
|
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||||
|
std::istreambuf_iterator<char>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hand-rolled package framing --------------------------------------------
|
||||||
|
|
||||||
|
static void putU32(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||||
|
for (int b = 0; b < 4; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFFu));
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<std::uint8_t> frame(std::uint32_t formatVersion,
|
||||||
|
std::uint32_t minReaderVersion,
|
||||||
|
const std::string& writerSemver,
|
||||||
|
const std::string& manifestJson,
|
||||||
|
const std::vector<std::vector<std::uint8_t>>& payloads) {
|
||||||
|
std::vector<std::uint8_t> out(kPackageMagic, kPackageMagic + 4);
|
||||||
|
putU32(out, formatVersion);
|
||||||
|
putU32(out, minReaderVersion);
|
||||||
|
putU32(out, static_cast<std::uint32_t>(writerSemver.size()));
|
||||||
|
out.insert(out.end(), writerSemver.begin(), writerSemver.end());
|
||||||
|
putU32(out, static_cast<std::uint32_t>(manifestJson.size()));
|
||||||
|
out.insert(out.end(), manifestJson.begin(), manifestJson.end());
|
||||||
|
for (const auto& p : payloads) out.insert(out.end(), p.begin(), p.end());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<std::uint8_t> payloadOf(std::size_t n, std::uint8_t seed) {
|
||||||
|
std::vector<std::uint8_t> v(n);
|
||||||
|
for (std::size_t i = 0; i < n; ++i) v[i] = static_cast<std::uint8_t>(seed + i * 13u);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
PackageManifest manifest;
|
||||||
|
std::vector<std::vector<std::uint8_t>> payloads;
|
||||||
|
};
|
||||||
|
|
||||||
|
static void addEntry(Fixture& f, const std::string& fileName, const std::string& id,
|
||||||
|
const std::string& contentHash, std::uint8_t seed) {
|
||||||
|
std::vector<std::uint8_t> payload = payloadOf(48 + seed, seed);
|
||||||
|
PackageEntry e;
|
||||||
|
e.fileName = fileName;
|
||||||
|
e.byteLength = payload.size();
|
||||||
|
e.byteHash = capture::hashBytes(payload.data(), payload.size());
|
||||||
|
e.sample.id = id;
|
||||||
|
e.sample.displayName = id;
|
||||||
|
e.sample.relativePath = "reasampler_bank/" + fileName;
|
||||||
|
e.sample.contentHash = contentHash;
|
||||||
|
f.manifest.entries.push_back(std::move(e));
|
||||||
|
f.payloads.push_back(std::move(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Fixture twoEntryFixture(const std::string& bankName) {
|
||||||
|
Fixture f;
|
||||||
|
f.manifest.bankDisplayName = bankName;
|
||||||
|
addEntry(f, "kick.wav", "cap-kick", "h-kick", 1);
|
||||||
|
addEntry(f, "snare.wav", "cap-snare", "h-snare", 2);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frames `f` with this build's own ladder pair unless overridden.
|
||||||
|
static std::vector<std::uint8_t> packageBytes(const Fixture& f,
|
||||||
|
std::uint32_t formatVersion = kPackageFormatVersion,
|
||||||
|
std::uint32_t minReader = kPackageMinReaderVersion,
|
||||||
|
const std::string& manifestOverride = {}) {
|
||||||
|
const auto json = serializeManifest(f.manifest);
|
||||||
|
const std::string body = manifestOverride.empty() ? *json : manifestOverride;
|
||||||
|
return frame(formatVersion, minReader, version::stampVersion(), body, f.payloads);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the sequence the verb runs, minus REAPER --------------------------------
|
||||||
|
|
||||||
|
struct RunResult {
|
||||||
|
ImportLanding landing;
|
||||||
|
bool applied = false;
|
||||||
|
tracking::OriginLedger ledger;
|
||||||
|
};
|
||||||
|
|
||||||
|
static RunResult runImport(const Scratch& scratch, BankBook& book,
|
||||||
|
const std::string& bankId = kBankId) {
|
||||||
|
RunResult r;
|
||||||
|
LandedFileJournal journal;
|
||||||
|
r.landing = landPackage(scratch.packagePath(), scratch.projectDir(), book, kTag, journal);
|
||||||
|
if (r.landing.outcome != ImportOutcome::Landed) return r;
|
||||||
|
r.applied = applyImportedBank(book, bankId, r.landing.plan, [&r](const Sample& s) {
|
||||||
|
tracking::OriginRecord rec;
|
||||||
|
rec.relativePath = s.relativePath;
|
||||||
|
rec.kind = tracking::OriginKind::PackageImport;
|
||||||
|
rec.sampleId = s.id;
|
||||||
|
r.ledger.record(rec);
|
||||||
|
});
|
||||||
|
if (r.applied) journal.markIndexCommitted();
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- suites ------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testCleanImportLandsEveryPayloadByteExact() {
|
||||||
|
Scratch scratch("clean");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::Landed);
|
||||||
|
CHECK(r.applied);
|
||||||
|
CHECK(scratch.bankFiles().size() == 2);
|
||||||
|
for (std::size_t i = 0; i < 2; ++i) {
|
||||||
|
const std::string name = r.landing.plan.entries[i].destFileName;
|
||||||
|
CHECK(readBytes(scratch.bankDir() + "/" + name) == f.payloads[i]);
|
||||||
|
}
|
||||||
|
const Bank* bank = book.bank(kBankId);
|
||||||
|
CHECK(bank != nullptr && bank->displayName == "Drums");
|
||||||
|
CHECK(bank->index.size() == 2);
|
||||||
|
CHECK(PayloadBuffer::alive() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testEveryLandedFileHasAPackageImportBirthRecord() {
|
||||||
|
Scratch scratch("births");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
CHECK(r.applied);
|
||||||
|
|
||||||
|
// Read the ledger back rather than counting calls.
|
||||||
|
CHECK(r.ledger.size() == 2);
|
||||||
|
for (const std::string& name : scratch.bankFiles()) {
|
||||||
|
const tracking::OriginRecord* rec =
|
||||||
|
r.ledger.find("reasampler_bank/" + name);
|
||||||
|
CHECK(rec != nullptr);
|
||||||
|
if (rec) CHECK(rec->kind == tracking::OriginKind::PackageImport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testACollapsedEntryLeavesNoFileBehind() {
|
||||||
|
Scratch scratch("collapse");
|
||||||
|
Fixture f;
|
||||||
|
f.manifest.bankDisplayName = "Drums";
|
||||||
|
addEntry(f, "kick.wav", "cap-a", "same-hash", 1);
|
||||||
|
addEntry(f, "kick_copy.wav", "cap-b", "same-hash", 2);
|
||||||
|
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::Landed);
|
||||||
|
// One file, one entry, one birth record — the dedup never manufactured an orphan.
|
||||||
|
CHECK(scratch.bankFiles().size() == 1);
|
||||||
|
CHECK(book.bank(kBankId)->index.size() == 1);
|
||||||
|
CHECK(r.ledger.size() == 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testHashMismatchLandsNothingAndMutatesNothing() {
|
||||||
|
Scratch scratch("integrity");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
std::vector<std::uint8_t> bytes = packageBytes(f);
|
||||||
|
bytes.back() ^= 0xFFu; // corrupt the LAST entry's payload
|
||||||
|
writeBytes(scratch.packagePath(), bytes);
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const BankBook before = book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::IntegrityFailed);
|
||||||
|
CHECK(r.landing.failedEntryName == "snare.wav");
|
||||||
|
// Refused BEFORE landing anything, not landed-then-rolled-back: the bank folder is
|
||||||
|
// created on the way into the write loop, so its absence dates the refusal.
|
||||||
|
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||||
|
CHECK(r.landing.rollback.deletedCount == 0);
|
||||||
|
CHECK(book == before); // zero index mutation
|
||||||
|
CHECK(!r.applied);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testWriteFailureAtEntryTwoRollsBackEntryOne() {
|
||||||
|
Scratch scratch("rollback");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||||
|
|
||||||
|
// Occupy the second entry's destination with a DIRECTORY: listFolderFileNames sees
|
||||||
|
// regular files only, so the plan does not rename around it, and the exclusive
|
||||||
|
// create then fails exactly where the injection wants it.
|
||||||
|
std::error_code ec;
|
||||||
|
fs::create_directories(utf8Path(scratch.bankDir()), ec);
|
||||||
|
fs::create_directory(utf8Path(scratch.bankDir() + "/snare.wav"), ec);
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const BankBook before = book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::WriteFailed);
|
||||||
|
CHECK(r.landing.failedEntryName == "snare.wav");
|
||||||
|
CHECK(r.landing.rollback.deletedCount == 1); // entry one was rolled back
|
||||||
|
CHECK(scratch.bankFiles().empty()); // nothing from this import survives
|
||||||
|
CHECK(book == before);
|
||||||
|
CHECK(PayloadBuffer::alive() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTooNewRefusesWholeAndNamesTheWriter() {
|
||||||
|
Scratch scratch("toonew");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
writeBytes(scratch.packagePath(),
|
||||||
|
packageBytes(f, kPackageFormatVersion + 1, kPackageFormatVersion + 1));
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const BankBook before = book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::TooNew);
|
||||||
|
// All three facts the refusal must name are available from the landing.
|
||||||
|
CHECK(r.landing.header.minReaderVersion == kPackageFormatVersion + 1);
|
||||||
|
CHECK(r.landing.header.writerVersion == version::stampVersion());
|
||||||
|
// The refusal returns before the bank folder is even created.
|
||||||
|
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||||
|
CHECK(book == before);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testNewerFormatVersionStillImportsWhenTheReaderIsReachable() {
|
||||||
|
Scratch scratch("additive");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
// An additive newer writer: formatVersion moved, minReaderVersion did not, and the
|
||||||
|
// manifest carries a key this build has never heard of.
|
||||||
|
std::string json = *serializeManifest(f.manifest);
|
||||||
|
CHECK(!json.empty() && json.front() == '{');
|
||||||
|
json.insert(1, "\"futureKey\":{\"nested\":[1,2,3]},");
|
||||||
|
writeBytes(scratch.packagePath(),
|
||||||
|
packageBytes(f, kPackageFormatVersion + 1, kPackageMinReaderVersion, json));
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::Landed);
|
||||||
|
CHECK(r.landing.header.formatVersion == kPackageFormatVersion + 1);
|
||||||
|
CHECK(book.bank(kBankId)->index.size() == 2);
|
||||||
|
CHECK(scratch.bankFiles().size() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTruncationAndGarbageReportMalformedNotTooNew() {
|
||||||
|
{
|
||||||
|
Scratch scratch("truncated");
|
||||||
|
const Fixture f = twoEntryFixture("Drums");
|
||||||
|
std::vector<std::uint8_t> bytes = packageBytes(f);
|
||||||
|
bytes.pop_back();
|
||||||
|
writeBytes(scratch.packagePath(), bytes);
|
||||||
|
|
||||||
|
BankBook book;
|
||||||
|
const BankBook before = book;
|
||||||
|
const RunResult r = runImport(scratch, book);
|
||||||
|
CHECK(r.landing.outcome == ImportOutcome::Malformed);
|
||||||
|
CHECK(book == before);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
Scratch scratch("garbage");
|
||||||
|
writeBytes(scratch.packagePath(), payloadOf(200, 9));
|
||||||
|
BankBook book;
|
||||||
|
CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Malformed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testMissingPackageFileIsUnreadableNotMalformed() {
|
||||||
|
Scratch scratch("absent");
|
||||||
|
BankBook book;
|
||||||
|
CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Unreadable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testUnsavedProjectRefusesBeforeAnythingIsRead() {
|
||||||
|
Scratch scratch("noproject");
|
||||||
|
writeBytes(scratch.packagePath(), packageBytes(twoEntryFixture("Drums")));
|
||||||
|
BankBook book;
|
||||||
|
LandedFileJournal journal;
|
||||||
|
const ImportLanding landing =
|
||||||
|
landPackage(scratch.packagePath(), std::string{}, book, kTag, journal);
|
||||||
|
CHECK(landing.outcome == ImportOutcome::NoProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal() {
|
||||||
|
Scratch scratch("roundtrip");
|
||||||
|
const Fixture f = twoEntryFixture("B");
|
||||||
|
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||||
|
|
||||||
|
// A project that already holds bank "B" with the package's own entries.
|
||||||
|
BankBook book;
|
||||||
|
book.createBank("bank-b", "B");
|
||||||
|
for (const PackageEntry& e : f.manifest.entries) book.index("bank-b")->add(e.sample);
|
||||||
|
const BankModel originalB = *book.index("bank-b");
|
||||||
|
|
||||||
|
const RunResult second = runImport(scratch, book, "bank-b2");
|
||||||
|
CHECK(second.landing.outcome == ImportOutcome::Landed);
|
||||||
|
CHECK(book.bank("bank-b2")->displayName == "B 2");
|
||||||
|
CHECK(book.bank("bank-b2")->index.size() == 2);
|
||||||
|
CHECK(*book.index("bank-b") == originalB); // B itself unmutated
|
||||||
|
for (const Sample& s : book.index("bank-b2")->all()) {
|
||||||
|
CHECK(s.id.rfind(kImportIdPrefix, 0) == 0);
|
||||||
|
CHECK(s.id != "cap-kick" && s.id != "cap-snare");
|
||||||
|
}
|
||||||
|
|
||||||
|
const RunResult third = runImport(scratch, book, "bank-b3");
|
||||||
|
CHECK(third.landing.outcome == ImportOutcome::Landed);
|
||||||
|
CHECK(book.bank("bank-b3")->displayName == "B 3");
|
||||||
|
CHECK(*book.index("bank-b") == originalB);
|
||||||
|
// Four distinct files: two per re-import, never overwritten. Bank "B"'s own two
|
||||||
|
// entries were seeded index-only above (book.index("bank-b")->add), never written
|
||||||
|
// to disk, so they don't add to this count.
|
||||||
|
CHECK(scratch.bankFiles().size() == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testCleanImportLandsEveryPayloadByteExact();
|
||||||
|
testEveryLandedFileHasAPackageImportBirthRecord();
|
||||||
|
testACollapsedEntryLeavesNoFileBehind();
|
||||||
|
testHashMismatchLandsNothingAndMutatesNothing();
|
||||||
|
testWriteFailureAtEntryTwoRollsBackEntryOne();
|
||||||
|
testTooNewRefusesWholeAndNamesTheWriter();
|
||||||
|
testNewerFormatVersionStillImportsWhenTheReaderIsReachable();
|
||||||
|
testTruncationAndGarbageReportMalformedNotTooNew();
|
||||||
|
testMissingPackageFileIsUnreadableNotMalformed();
|
||||||
|
testUnsavedProjectRefusesBeforeAnythingIsRead();
|
||||||
|
testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("import_landing: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
// Standalone tests for reasampler::package::import_plan — no REAPER, no filesystem,
|
||||||
|
// no test framework. Every one of the four collision classes (sample id, bank-folder
|
||||||
|
// file name, content hash, bank display name) is exercised here, which is the point of
|
||||||
|
// the module: the whole collision rule set is decidable from strings and hashes.
|
||||||
|
|
||||||
|
#include "../src/core/package/import_plan.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../src/core/tracking/tracking_authority.h"
|
||||||
|
|
||||||
|
using namespace reasampler;
|
||||||
|
using namespace reasampler::package;
|
||||||
|
using reasampler::model::Sample;
|
||||||
|
using reasampler::model::SlotMap;
|
||||||
|
|
||||||
|
static int g_fail = 0;
|
||||||
|
#define CHECK(cond) do { if(!(cond)) { \
|
||||||
|
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||||
|
|
||||||
|
static const char* kProjectDir = "/proj";
|
||||||
|
static const char* kTag = "1754000000";
|
||||||
|
|
||||||
|
// --- fixtures ----------------------------------------------------------------
|
||||||
|
|
||||||
|
static PackageEntry entry(const std::string& fileName, const std::string& id,
|
||||||
|
const std::string& hash) {
|
||||||
|
PackageEntry e;
|
||||||
|
e.fileName = fileName;
|
||||||
|
e.byteLength = 64;
|
||||||
|
e.byteHash = "0011223344556677";
|
||||||
|
e.sample.id = id;
|
||||||
|
e.sample.displayName = id;
|
||||||
|
e.sample.relativePath = "reasampler_bank/" + fileName;
|
||||||
|
e.sample.contentHash = hash;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static PackageManifest manifestOf(std::vector<PackageEntry> entries,
|
||||||
|
const std::string& bankName) {
|
||||||
|
PackageManifest m;
|
||||||
|
m.bankDisplayName = bankName;
|
||||||
|
m.entries = std::move(entries);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A book carrying the named banks, in order, each with a caller-supplied id.
|
||||||
|
static BankBook bookWithBanks(const std::vector<std::string>& names) {
|
||||||
|
BankBook book;
|
||||||
|
for (std::size_t i = 0; i < names.size(); ++i)
|
||||||
|
book.createBank("bank-" + std::to_string(i), names[i]);
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
static const PlannedEntry& landed(const ImportPlan& plan, std::size_t manifestIndex) {
|
||||||
|
return plan.entries[manifestIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the bank-name probe (collision class 4) ---------------------------------
|
||||||
|
|
||||||
|
static std::string plannedName(const std::vector<std::string>& existingBanks,
|
||||||
|
const std::string& packageBankName) {
|
||||||
|
const BankBook book = bookWithBanks(existingBanks);
|
||||||
|
return planImport(manifestOf({}, packageBankName), book, kProjectDir, {}, kTag)
|
||||||
|
.bankDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFreeSeedIsKeptVerbatim() {
|
||||||
|
CHECK(plannedName({"Percussion"}, "Drums") == "Drums");
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({}, "Drums"), bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(!plan.bankNameAdjusted);
|
||||||
|
CHECK(plan.seedBankName == "Drums");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testFoldedCollisionTakesTheFirstSuffix() {
|
||||||
|
// The book's fold is case- and whitespace-insensitive, so "drums" blocks "Drums".
|
||||||
|
CHECK(plannedName({"drums"}, "Drums") == "Drums 2");
|
||||||
|
CHECK(plannedName({" DRUMS "}, "Drums") == "Drums 2");
|
||||||
|
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({}, "Drums"), bookWithBanks({"drums"}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(plan.bankNameAdjusted);
|
||||||
|
CHECK(plan.seedBankName == "Drums"); // the message needs what was asked for
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testProbeFillsAGap() {
|
||||||
|
// First-free-ascending, not highest-plus-one: "Drums 2" is free, so it wins.
|
||||||
|
CHECK(plannedName({"Drums", "Drums 3"}, "Drums") == "Drums 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testSeedIsNeverReparsed() {
|
||||||
|
// "Drums 2" colliding lands as "Drums 2 2", NOT "Drums 3" — a bare trailing integer
|
||||||
|
// cannot be told from a name the user wrote.
|
||||||
|
CHECK(plannedName({"Drums 2"}, "Drums 2") == "Drums 2 2");
|
||||||
|
CHECK(plannedName({"Kit 808"}, "Kit 808") == "Kit 808 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testBlankRecordedNameFallsBackToTheDefault() {
|
||||||
|
CHECK(plannedName({}, "") == kDefaultImportBankName);
|
||||||
|
CHECK(plannedName({}, " \t ") == kDefaultImportBankName);
|
||||||
|
// And the fallback is a seed like any other, so a second one suffixes.
|
||||||
|
CHECK(plannedName({kDefaultImportBankName}, "") ==
|
||||||
|
std::string(kDefaultImportBankName) + " 2");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testPoolExportLandsAsANamedBank() {
|
||||||
|
// The destination's pool always exists and always carries the protected name
|
||||||
|
// "Pool", so a pool export imports as a NAMED bank "Pool 2" — intended, not a glitch.
|
||||||
|
CHECK(plannedName({}, "Pool") == "Pool 2");
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({}, "Pool"), bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(plan.bankNameAdjusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testRepeatedImportsWalkTheSuffixUpwards() {
|
||||||
|
CHECK(plannedName({"B"}, "B") == "B 2");
|
||||||
|
CHECK(plannedName({"B", "B 2"}, "B") == "B 3");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- sample ids (collision class 1) ------------------------------------------
|
||||||
|
|
||||||
|
static void testEveryIdIsRemintedUnderTheImportPrefix() {
|
||||||
|
const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1"),
|
||||||
|
entry("snare.wav", "cap-2-snare.wav", "h2")},
|
||||||
|
"Drums");
|
||||||
|
const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
|
||||||
|
CHECK(plan.landCount == 2);
|
||||||
|
for (const PlannedEntry& e : plan.entries) {
|
||||||
|
CHECK(e.sample.id.rfind(kImportIdPrefix, 0) == 0);
|
||||||
|
CHECK(e.sample.id != "cap-1-kick.wav");
|
||||||
|
CHECK(e.sample.id != "cap-2-snare.wav");
|
||||||
|
}
|
||||||
|
CHECK(plan.entries[0].sample.id != plan.entries[1].sample.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testReimportingIntoTheSourceProjectRemintsRatherThanCollides() {
|
||||||
|
// The package came FROM this project, so its ids are the ones already in use.
|
||||||
|
BankBook book = bookWithBanks({"B"});
|
||||||
|
Sample existing;
|
||||||
|
existing.id = "cap-1-kick.wav";
|
||||||
|
existing.relativePath = "reasampler_bank/kick.wav";
|
||||||
|
existing.contentHash = "h1";
|
||||||
|
book.index("bank-0")->add(existing);
|
||||||
|
|
||||||
|
const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1")}, "B");
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(m, book, kProjectDir, {"kick.wav"}, kTag);
|
||||||
|
|
||||||
|
CHECK(plan.bankDisplayName == "B 2");
|
||||||
|
CHECK(landed(plan, 0).sample.id != "cap-1-kick.wav");
|
||||||
|
// The hash lives in another bank; cross-bank dedup is deliberately not enforced,
|
||||||
|
// so the entry still lands rather than collapsing onto B's copy.
|
||||||
|
CHECK(landed(plan, 0).action == EntryAction::Land);
|
||||||
|
CHECK(landed(plan, 0).renamed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParentIsRemappedWhenItTravelledInThePackage() {
|
||||||
|
PackageEntry parent = entry("kick.wav", "cap-parent", "h1");
|
||||||
|
PackageEntry child = entry("kick_r2.wav", "cap-child", "h2");
|
||||||
|
child.sample.provenance = model::Provenance{"cap-parent", "fx-snapshot"};
|
||||||
|
|
||||||
|
const ImportPlan plan = planImport(manifestOf({parent, child}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
|
||||||
|
CHECK(landed(plan, 1).sample.provenance.has_value());
|
||||||
|
CHECK(landed(plan, 1).sample.provenance->parentSampleId ==
|
||||||
|
landed(plan, 0).sample.id);
|
||||||
|
CHECK(landed(plan, 1).sample.provenance->fxChainSnapshot == "fx-snapshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testParentIsRemappedEvenWhenItFollowsTheChild() {
|
||||||
|
// Manifest order does not constrain lineage, so the remap runs after every id is minted.
|
||||||
|
PackageEntry child = entry("kick_r2.wav", "cap-child", "h2");
|
||||||
|
child.sample.provenance = model::Provenance{"cap-parent", ""};
|
||||||
|
PackageEntry parent = entry("kick.wav", "cap-parent", "h1");
|
||||||
|
|
||||||
|
const ImportPlan plan = planImport(manifestOf({child, parent}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(landed(plan, 0).sample.provenance->parentSampleId == landed(plan, 1).sample.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testForeignParentIsClearedNotCarried() {
|
||||||
|
PackageEntry child = entry("kick.wav", "cap-child", "h1");
|
||||||
|
child.sample.provenance = model::Provenance{"cap-not-in-this-package", "fx"};
|
||||||
|
|
||||||
|
const ImportPlan plan = planImport(manifestOf({child}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(landed(plan, 0).sample.provenance.has_value());
|
||||||
|
CHECK(landed(plan, 0).sample.provenance->parentSampleId.empty());
|
||||||
|
CHECK(landed(plan, 0).sample.provenance->fxChainSnapshot == "fx");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bank-folder file names (collision class 2) ------------------------------
|
||||||
|
|
||||||
|
static void testAFreeBankLegalNameIsKept() {
|
||||||
|
const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir,
|
||||||
|
{"unrelated.wav"}, kTag);
|
||||||
|
CHECK(landed(plan, 0).destFileName == "kick.wav");
|
||||||
|
CHECK(!landed(plan, 0).renamed);
|
||||||
|
CHECK(plan.collisionRenameCount == 0);
|
||||||
|
CHECK(plan.sanitizeRenameCount == 0);
|
||||||
|
CHECK(landed(plan, 0).sample.relativePath == "reasampler_bank/kick.wav");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testATakenNameIsMintedFreshAndNeverOverwritten() {
|
||||||
|
const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir,
|
||||||
|
{"kick.wav"}, kTag);
|
||||||
|
CHECK(landed(plan, 0).destFileName != "kick.wav");
|
||||||
|
CHECK(landed(plan, 0).renamed);
|
||||||
|
// A genuine folder-name collision, not a spelling mint.
|
||||||
|
CHECK(plan.collisionRenameCount == 1);
|
||||||
|
CHECK(plan.sanitizeRenameCount == 0);
|
||||||
|
CHECK(landed(plan, 0).sample.relativePath ==
|
||||||
|
"reasampler_bank/" + landed(plan, 0).destFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTheFolderNameCheckFoldsAsciiCase() {
|
||||||
|
// Windows and the default APFS would land "kick.wav" onto "KICK.WAV".
|
||||||
|
const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir,
|
||||||
|
{"KICK.WAV"}, kTag);
|
||||||
|
CHECK(landed(plan, 0).destFileName != "kick.wav");
|
||||||
|
CHECK(landed(plan, 0).renamed);
|
||||||
|
// The case-fold hit is still a collision, not a spelling mint.
|
||||||
|
CHECK(plan.collisionRenameCount == 1);
|
||||||
|
CHECK(plan.sanitizeRenameCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testTwoEntriesNeverLandOnOneName() {
|
||||||
|
// Two package names that differ only by case are one destination file.
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({entry("kick.wav", "a", "h1"), entry("Kick.wav", "b", "h2")},
|
||||||
|
"Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(plan.landCount == 2);
|
||||||
|
CHECK(landed(plan, 0).destFileName != landed(plan, 1).destFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim() {
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({entry("Hit One.wav", "a", "h1")}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(landed(plan, 0).destFileName.find(' ') == std::string::npos);
|
||||||
|
CHECK(landed(plan, 0).renamed);
|
||||||
|
// No collision here (the bank folder is empty) — this is a sanitize mint.
|
||||||
|
CHECK(plan.sanitizeRenameCount == 1);
|
||||||
|
CHECK(plan.collisionRenameCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- content hash (collision class 3) ----------------------------------------
|
||||||
|
|
||||||
|
static void testAnAlreadyLandedHashCollapsesWithoutAWrite() {
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({entry("kick.wav", "a", "same"),
|
||||||
|
entry("kick_copy.wav", "b", "same")},
|
||||||
|
"Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
|
||||||
|
CHECK(plan.landCount == 1);
|
||||||
|
CHECK(plan.collapseCount == 1);
|
||||||
|
CHECK(landed(plan, 0).action == EntryAction::Land);
|
||||||
|
CHECK(landed(plan, 1).action == EntryAction::Collapse);
|
||||||
|
// No name is claimed for it — a dedup that wrote a file would manufacture an orphan.
|
||||||
|
CHECK(landed(plan, 1).destFileName.empty());
|
||||||
|
// Every manifest entry still yields exactly one planned entry: planImport is total.
|
||||||
|
CHECK(plan.entries.size() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAParentPointingAtACollapsedEntryResolvesToTheSurvivor() {
|
||||||
|
PackageEntry first = entry("kick.wav", "cap-first", "same");
|
||||||
|
PackageEntry dupe = entry("kick_copy.wav", "cap-dupe", "same");
|
||||||
|
PackageEntry child = entry("kick_r2.wav", "cap-child", "other");
|
||||||
|
child.sample.provenance = model::Provenance{"cap-dupe", ""};
|
||||||
|
|
||||||
|
const ImportPlan plan = planImport(manifestOf({first, dupe, child}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(landed(plan, 2).sample.provenance->parentSampleId == landed(plan, 0).sample.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAnEmptyHashNeverCollapses() {
|
||||||
|
// Mirrors findByHash: an unhashable entry does not participate in dedup.
|
||||||
|
const ImportPlan plan =
|
||||||
|
planImport(manifestOf({entry("a.wav", "a", ""), entry("b.wav", "b", "")}, "Drums"),
|
||||||
|
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(plan.landCount == 2);
|
||||||
|
CHECK(plan.collapseCount == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- slots -------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void testSlotsRideAlongOverTheRemintedIds() {
|
||||||
|
PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "h1"),
|
||||||
|
entry("snare.wav", "cap-b", "h2")},
|
||||||
|
"Drums");
|
||||||
|
// A gap the package carried: slot 0 empty, occupants at 1 and 3.
|
||||||
|
m.slots = SlotMap::fromEntries({{"cap-a", 1}, {"cap-b", 3}});
|
||||||
|
|
||||||
|
const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 1);
|
||||||
|
CHECK(plan.slots.slotOf(landed(plan, 1).sample.id) == 3);
|
||||||
|
// The package's own ids are gone from the map — a foreign id never enters the index.
|
||||||
|
CHECK(plan.slots.slotOf("cap-a") == -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testACollapsedEntryDoesNotDoubleOccupyASlot() {
|
||||||
|
PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "same"),
|
||||||
|
entry("kick_copy.wav", "cap-b", "same")},
|
||||||
|
"Drums");
|
||||||
|
m.slots = SlotMap::fromEntries({{"cap-a", 0}, {"cap-b", 1}});
|
||||||
|
|
||||||
|
const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
|
||||||
|
CHECK(plan.slots.size() == 1);
|
||||||
|
CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the ledger gate ---------------------------------------------------------
|
||||||
|
|
||||||
|
static void testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot() {
|
||||||
|
CHECK(importLedgerRefusal(tracking::LedgerStatus::Unreadable) ==
|
||||||
|
LedgerRefusal::Malformed);
|
||||||
|
CHECK(importLedgerRefusal(tracking::LedgerStatus::FutureVersion) ==
|
||||||
|
LedgerRefusal::FutureVersion);
|
||||||
|
CHECK(importLedgerRefusal(tracking::LedgerStatus::Fresh) == LedgerRefusal::None);
|
||||||
|
CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testAnUndecodableUsageKeyBlocksPruneButNotImport() {
|
||||||
|
// The tempting reuse of PruneReport::blockedByTracking would silently refuse an
|
||||||
|
// import over a key that only ever governs what a DELETION may touch.
|
||||||
|
tracking::OriginLedger ledger;
|
||||||
|
wire::UsageFoldResult usage;
|
||||||
|
usage.abortPrune = true;
|
||||||
|
usage.offendingKeys = {"rsusage_{ABC}"};
|
||||||
|
|
||||||
|
const tracking::TrackingState state{tracking::LedgerStatus::Loaded, ledger, usage};
|
||||||
|
CHECK(tracking::pruneProtection(state).blocked);
|
||||||
|
CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins the delegation itself: importLedgerRefusal must refuse EXACTLY the statuses
|
||||||
|
// ledgerDegraded() calls degraded, over every value the enum has today. A gate that
|
||||||
|
// re-derived its own notion of "degraded" could silently diverge from this the moment
|
||||||
|
// either side changes without the other.
|
||||||
|
static void testImportLedgerRefusalDelegatesToLedgerDegraded() {
|
||||||
|
const tracking::LedgerStatus all[] = {
|
||||||
|
tracking::LedgerStatus::Fresh,
|
||||||
|
tracking::LedgerStatus::Loaded,
|
||||||
|
tracking::LedgerStatus::Unreadable,
|
||||||
|
tracking::LedgerStatus::FutureVersion,
|
||||||
|
};
|
||||||
|
for (tracking::LedgerStatus s : all)
|
||||||
|
CHECK((importLedgerRefusal(s) != LedgerRefusal::None) == tracking::ledgerDegraded(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the ledger-refusal message (pure, so both channels are assertable without a DAW) --
|
||||||
|
|
||||||
|
static void testLedgerRefusalMessageIsEmptyForNone() {
|
||||||
|
CHECK(ledgerRefusalMessage(LedgerRefusal::None, "reasampler").empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLedgerRefusalMessageNamesTheChannelCorrectNamespace() {
|
||||||
|
// The two real namespaces (app_version.h): stable "reasampler", beta "reasampler_beta".
|
||||||
|
const std::string stable = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
|
||||||
|
CHECK(stable.find("\"reasampler\"") != std::string::npos);
|
||||||
|
CHECK(stable.find("reasampler_beta") == std::string::npos);
|
||||||
|
|
||||||
|
const std::string beta = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler_beta");
|
||||||
|
CHECK(beta.find("\"reasampler_beta\"") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion() {
|
||||||
|
const std::string malformed = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
|
||||||
|
const std::string futureVersion =
|
||||||
|
ledgerRefusalMessage(LedgerRefusal::FutureVersion, "reasampler");
|
||||||
|
CHECK(malformed != futureVersion);
|
||||||
|
CHECK(malformed.find("malformed") != std::string::npos);
|
||||||
|
CHECK(futureVersion.find("NEWER version") != std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
testFreeSeedIsKeptVerbatim();
|
||||||
|
testFoldedCollisionTakesTheFirstSuffix();
|
||||||
|
testProbeFillsAGap();
|
||||||
|
testSeedIsNeverReparsed();
|
||||||
|
testBlankRecordedNameFallsBackToTheDefault();
|
||||||
|
testPoolExportLandsAsANamedBank();
|
||||||
|
testRepeatedImportsWalkTheSuffixUpwards();
|
||||||
|
|
||||||
|
testEveryIdIsRemintedUnderTheImportPrefix();
|
||||||
|
testReimportingIntoTheSourceProjectRemintsRatherThanCollides();
|
||||||
|
testParentIsRemappedWhenItTravelledInThePackage();
|
||||||
|
testParentIsRemappedEvenWhenItFollowsTheChild();
|
||||||
|
testForeignParentIsClearedNotCarried();
|
||||||
|
|
||||||
|
testAFreeBankLegalNameIsKept();
|
||||||
|
testATakenNameIsMintedFreshAndNeverOverwritten();
|
||||||
|
testTheFolderNameCheckFoldsAsciiCase();
|
||||||
|
testTwoEntriesNeverLandOnOneName();
|
||||||
|
testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim();
|
||||||
|
|
||||||
|
testAnAlreadyLandedHashCollapsesWithoutAWrite();
|
||||||
|
testAParentPointingAtACollapsedEntryResolvesToTheSurvivor();
|
||||||
|
testAnEmptyHashNeverCollapses();
|
||||||
|
|
||||||
|
testSlotsRideAlongOverTheRemintedIds();
|
||||||
|
testACollapsedEntryDoesNotDoubleOccupyASlot();
|
||||||
|
|
||||||
|
testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot();
|
||||||
|
testAnUndecodableUsageKeyBlocksPruneButNotImport();
|
||||||
|
testImportLedgerRefusalDelegatesToLedgerDegraded();
|
||||||
|
|
||||||
|
testLedgerRefusalMessageIsEmptyForNone();
|
||||||
|
testLedgerRefusalMessageNamesTheChannelCorrectNamespace();
|
||||||
|
testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion();
|
||||||
|
|
||||||
|
if (g_fail == 0) std::printf("import_plan: all tests passed\n");
|
||||||
|
return g_fail == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user