import: a .rsbank lands as a new bank, whole or not at all
Four collisions answered explicitly: ids reminted, names never overwritten, content deduped before the write, bank name auto-suffixed. Degraded ledger refuses before the picker.
This commit is contained in:
@@ -58,6 +58,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.
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include "shell/panel/panel_window.h" // panel lifecycle (init/toggle/open-query/shutdown)
|
||||
#include "shell/persist/session.h" // ReaSamplerSession
|
||||
#include "shell/view/view.h" // reconcileManagedLanes / applyMode
|
||||
#include "shell/actions/package_import_action.h" // bank-package import action body
|
||||
|
||||
namespace capture = reasampler::capture;
|
||||
|
||||
@@ -91,6 +92,7 @@ static void RunCancelRealtime(int) { capture::RunCancelRealtime(g_session); }
|
||||
static void RunRecaptureFromSource(int) { capture::RunRecaptureFromSource(g_session); }
|
||||
static void RunRenderTrackInPlace(int) { capture::RunRenderTrackInPlace(g_session); }
|
||||
static void RunResampleBake(int) { capture::RunResampleBake(g_session); }
|
||||
static void RunImportBankPackage(int) { reasampler::doImportBankPackage(g_session); }
|
||||
static void RunShowVersion(int) {
|
||||
// On-demand only — no unconditional startup print (routine console chatter pops
|
||||
// the console window).
|
||||
@@ -148,6 +150,8 @@ static std::vector<reasampler::ActionTableRow> buildMainActionTable() {
|
||||
"land pending ReaSampler 9000 resample bake",
|
||||
&RunResampleBake});
|
||||
rows.push_back({"SHOW_VERSION", "show version", &RunShowVersion});
|
||||
rows.push_back({"IMPORT_BANK_PACKAGE", "import bank package (.rsbank)",
|
||||
&RunImportBankPackage});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,17 @@ bool BankBook::createBank(const std::string& id, const std::string& displayName)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Runs behind displayNameTaken so the probe and the create/rename check can never
|
||||
// disagree about what "already used" means. exceptId is deliberately "" — no bank can
|
||||
// carry an empty id, so nothing is excluded from the scan.
|
||||
std::string BankBook::uniqueDisplayName(const std::string& seed) const {
|
||||
if (!displayNameTaken(seed, /*exceptId=*/std::string{})) return seed;
|
||||
for (int n = 2;; ++n) {
|
||||
std::string candidate = seed + " " + std::to_string(n);
|
||||
if (!displayNameTaken(candidate, /*exceptId=*/std::string{})) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
bool BankBook::renameBank(const std::string& id, const std::string& displayName) {
|
||||
if (id == kPoolBankId) return false; // pool is un-renamable
|
||||
Bank* b = bank(id);
|
||||
|
||||
@@ -100,6 +100,17 @@ public:
|
||||
// no-op success.
|
||||
bool renameBank(const std::string& id, const std::string& displayName);
|
||||
|
||||
// The first name in the sequence `seed`, "seed 2", "seed 3", … whose fold is free
|
||||
// in this book — what a caller that must not be rejected (the package import) asks
|
||||
// for before createBank. First-FREE-ascending, not highest-plus-one, so it fills a
|
||||
// gap ("Drums" + "Drums 3" present yields "Drums 2") and is a pure function of the
|
||||
// current name set. The seed is returned verbatim when free and is NEVER re-parsed:
|
||||
// a bare trailing integer cannot be told from a user's own name, so "Kit 808" would
|
||||
// become "Kit 2" under a stripping rule. Terminates by pigeonhole (one of the first
|
||||
// N+1 candidates is free for N banks), so it needs no cap. A blank seed comes back
|
||||
// blank — what a missing name should become is the caller's policy, not the model's.
|
||||
std::string uniqueDisplayName(const std::string& seed) const;
|
||||
|
||||
// Deletes a named bank and its member entries (files untouched — a shell/prune
|
||||
// concern). Rejects (false, no mutation) an unknown id or the pool. Remaining
|
||||
// ordinals compact after; if the deleted bank was active, falls back to the pool.
|
||||
|
||||
@@ -73,6 +73,11 @@ landing after the format.
|
||||
here, computed where payloads are streamed (shell). The bank's `slot_map`
|
||||
rides along. Unknown keys skip at every level; duplicate entry names are
|
||||
rejected both ways.
|
||||
- `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.
|
||||
- `bank_package` — framing and arithmetic composing the two above:
|
||||
`encodePackage` (prefix bytes + layout + total size, stamping this build's
|
||||
ladder pair and `version::stampVersion()`), `decodePackage` (prefix + observed
|
||||
@@ -91,6 +96,11 @@ landing after the format.
|
||||
deliberately NOT enforced by the codec — they are `import_plan` decisions. The
|
||||
codec rejects only what makes the container itself incoherent (duplicate
|
||||
entry names, invalid names, a non-single-sample nested index).
|
||||
- **`import_plan` consults no other bank's hashes, and that is the ruling, not
|
||||
an omission.** An import always creates a NEW bank, so "already present in the
|
||||
destination bank by content" is exactly "already landed by this same plan".
|
||||
Cross-bank dedup is not enforced anywhere (`core/model/CLAUDE.md`), so a hash
|
||||
the pool already holds still lands its own file here.
|
||||
- `requiredPrefixSize` trusts fields beyond the frozen region only when the
|
||||
version pair classifies `Readable`; for `TooNew` it stops at the semver —
|
||||
don't "fix" it to read the manifest length there, a future structural format
|
||||
@@ -133,15 +143,13 @@ landing after the format.
|
||||
collision class as the ASCII case fold, which `sameEntryName` does catch. A
|
||||
table-free fix does not exist, and restricting names to ASCII would be
|
||||
genuinely over-strict for non-English users. Left open knowingly.
|
||||
- **`duplicateName` is O(n²) over `entries` on the decode path** — pre-existing
|
||||
shape (the double loop is unchanged since `af35fc5`; only the comparator
|
||||
changed). Under the `kMaxManifestBytes` cap (64 MB) a minimal entry is
|
||||
~100 bytes, so a hostile package can declare ~670k entries — ~2×10¹¹ pair
|
||||
comparisons, a multi-minute hang on import. It signals an error rather than
|
||||
UB, so the hostile-input invariant above still holds, but it sits against
|
||||
this module's "a forged header cannot demand gigabytes" posture. Forward
|
||||
obligation for `import_plan`: fold this into a sorted vector or hash set
|
||||
when that track lands; not changed here.
|
||||
- **`duplicateName` folds through a hash set, not a pairwise scan.** Under the
|
||||
`kMaxManifestBytes` cap (64 MB) a minimal entry is ~100 bytes, so a hostile
|
||||
package can declare ~670k entries; the former double loop was ~2×10¹¹ pair
|
||||
comparisons — a multi-minute hang on the decode path an import drives. The
|
||||
set is keyed on `entryNameKey`, which is `sameEntryName`'s ASCII-case fold
|
||||
made explicit, so the equivalence rule still has one home (`lowerAscii`).
|
||||
Do not reintroduce the pairwise scan.
|
||||
- **Cross-module contract with `src/shell/package`:** a genuinely zero-length
|
||||
entry cannot round-trip through the filesystem seam there (`appendPayload`
|
||||
refuses an empty payload — an empty buffer signals an upstream read failure,
|
||||
|
||||
@@ -12,3 +12,10 @@ reasampler_pure_library(bank_package
|
||||
LINK PUBLIC package_format package_manifest PRIVATE app_version)
|
||||
# app_version: the tests pin the stamped writer semver against stampVersion().
|
||||
reasampler_test(bank_package LINK bank_package app_version)
|
||||
|
||||
reasampler_pure_library(import_plan
|
||||
SOURCES import_plan.cpp
|
||||
LINK PUBLIC package_manifest bank_book origin_ledger PRIVATE package_format capture_paths)
|
||||
# tracking_authority: the ledger-gate test proves the import does NOT share prune's
|
||||
# composite blocker, which needs the composite to compare against.
|
||||
reasampler_test(import_plan LINK import_plan tracking_authority)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#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"
|
||||
|
||||
namespace reasampler::package {
|
||||
|
||||
namespace {
|
||||
|
||||
using capture::bankRelativeForName;
|
||||
using capture::deriveBankPaths;
|
||||
using capture::sanitizeStem;
|
||||
|
||||
bool blankName(const std::string& s) {
|
||||
for (char c : s)
|
||||
if (c != ' ' && c != '\t' && c != '\n' && c != '\r') 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.
|
||||
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) {
|
||||
switch (status) {
|
||||
case tracking::LedgerStatus::Unreadable: return LedgerRefusal::Malformed;
|
||||
case tracking::LedgerStatus::FutureVersion: return LedgerRefusal::FutureVersion;
|
||||
case tracking::LedgerStatus::Fresh:
|
||||
case tracking::LedgerStatus::Loaded: break;
|
||||
}
|
||||
return LedgerRefusal::None;
|
||||
}
|
||||
|
||||
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;
|
||||
if (e.renamed) ++plan.renameCount;
|
||||
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,76 @@
|
||||
#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);
|
||||
|
||||
// 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;
|
||||
int renameCount = 0;
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
std::string entryNameKey(const std::string& name) {
|
||||
std::string key;
|
||||
key.reserve(name.size());
|
||||
for (unsigned char c : name) key += lowerAscii(c);
|
||||
return key;
|
||||
}
|
||||
|
||||
bool isValidNestedSamplePath(const std::string& path) {
|
||||
if (util::isAbsolutePath(path)) return false;
|
||||
// Component-wise, not a substring scan: "take..final/a.wav" is a legal
|
||||
|
||||
@@ -76,6 +76,12 @@ bool isValidEntryName(const std::string& name);
|
||||
// bytes compare exactly (see this directory's CLAUDE.md on NFC/NFD).
|
||||
bool sameEntryName(const std::string& a, const std::string& b);
|
||||
|
||||
// sameEntryName's fold made explicit: the ASCII-lower-cased bytes, so
|
||||
// entryNameKey(a) == entryNameKey(b) exactly when sameEntryName(a, b). For a caller
|
||||
// holding many names at once — folding them into a set is what turns an O(n^2)
|
||||
// pairwise scan into a linear one.
|
||||
std::string entryNameKey(const std::string& name);
|
||||
|
||||
// The one field in the format that CAN express a path: a nested Sample's
|
||||
// relativePath, which is bank-relative by design. Rejects every absolute form
|
||||
// (the shared util::isAbsolutePath test) and any ".." component — BankModel::add
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "core/package/package_manifest.h"
|
||||
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
#include "core/json/json.h"
|
||||
@@ -14,11 +15,16 @@ using ObjWriter = json::Writer;
|
||||
|
||||
// Shared by serializeManifest and deserializeManifest — see this directory's
|
||||
// CLAUDE.md for why duplicate names are rejected both ways. Equivalence is the
|
||||
// format's, not std::string's: sameEntryName folds ASCII case.
|
||||
// format's, not std::string's: entryNameKey is sameEntryName's ASCII-case fold.
|
||||
//
|
||||
// A set, not the pairwise scan this replaced: under kMaxManifestBytes a hostile
|
||||
// package can declare hundreds of thousands of minimal entries, and O(n^2) over that
|
||||
// is a multi-minute hang on the decode path an import drives.
|
||||
bool duplicateName(const std::vector<PackageEntry>& entries) {
|
||||
for (std::size_t i = 0; i < entries.size(); ++i)
|
||||
for (std::size_t j = i + 1; j < entries.size(); ++j)
|
||||
if (sameEntryName(entries[i].fileName, entries[j].fileName)) return true;
|
||||
std::unordered_set<std::string> seen;
|
||||
seen.reserve(entries.size());
|
||||
for (const auto& e : entries)
|
||||
if (!seen.insert(entryNameKey(e.fileName)).second) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
The bindable action families routed through REAPER's `command_id`/`gaccel`/
|
||||
`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
|
||||
extension-side ingest-through-the-bank shell. This is
|
||||
where user-facing REAPER actions and OS-level drag/drop live; the underlying
|
||||
@@ -42,6 +43,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`.
|
||||
- `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.
|
||||
- `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. Owns every message the import produces; 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.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// 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 + "\""; }
|
||||
|
||||
// Mirrors prune's abort block in structure and tone, 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.
|
||||
void reportLedgerRefusal(package::LedgerRefusal refusal) {
|
||||
const std::string& ns = version::extStateNamespace();
|
||||
std::string msg =
|
||||
"ReaSampler import: ABORTED -- the file-tracking ledger could not be read. "
|
||||
"Nothing was imported.\n";
|
||||
if (refusal == package::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, \"" + ns + "\", \"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";
|
||||
ShowConsoleMsg(msg.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 std::string writer =
|
||||
r.header.writerVersion.empty() ? std::string("an unidentified build")
|
||||
: "ReaSampler " + r.header.writerVersion;
|
||||
const 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 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";
|
||||
if (r.renamedCount > 0) {
|
||||
detail += " " + std::to_string(r.renamedCount) +
|
||||
" file(s) landed under a freshly minted name (the package's own name "
|
||||
"was already taken in the bank folder, or was not spelled the way "
|
||||
"this bank spells a file). An existing bank file is never "
|
||||
"overwritten.\n";
|
||||
}
|
||||
if (r.collapsedCount > 0) {
|
||||
detail += " " + std::to_string(r.collapsedCount) +
|
||||
" sample(s) were already present by content and were not written "
|
||||
"again.\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".
|
||||
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
|
||||
|
||||
void doImportBankPackage(ReaSamplerSession& session) {
|
||||
if (!ledgerPermits(session)) return;
|
||||
std::string path;
|
||||
if (!pickPackageForImport(path) || path.empty()) return;
|
||||
report(importBankPackage(session, path));
|
||||
}
|
||||
|
||||
void doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath) {
|
||||
if (packageAbsPath.empty()) return;
|
||||
if (!ledgerPermits(session)) return;
|
||||
report(importBankPackage(session, packageAbsPath));
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,19 @@
|
||||
#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.
|
||||
void 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.
|
||||
void doImportBankPackageFile(ReaSamplerSession& session, const std::string& packageAbsPath);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -6,11 +6,12 @@ The filesystem and dialog acts behind bank-package export/import: streaming pack
|
||||
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
|
||||
journal and its rollback delete (`package_rollback`), and the two file pickers
|
||||
(`package_pickers`). This seam is bytes-only — the package format (magic, manifest,
|
||||
entry layout) is `core/package`'s business, and the export/import verbs that
|
||||
orchestrate both do not live here yet. No REAPER project state is touched in this
|
||||
directory: no ext-state read or write, no undo block, no generation bump — those
|
||||
belong to the verbs.
|
||||
(`package_pickers`). Those are bytes-only — the package format (magic, manifest, entry
|
||||
layout) is `core/package`'s business. Beside them sits the import verb, split so its
|
||||
decisions stay testable: `import_landing` (REAPER-free) decides and writes,
|
||||
`import_bank` owns the only REAPER project state this directory touches (the
|
||||
ext-state persist, the undo block, the generation bump). The export verb does not
|
||||
live here yet.
|
||||
|
||||
## Invariants
|
||||
|
||||
@@ -65,7 +66,14 @@ belong to the verbs.
|
||||
landed files with no index entry and a journal that now refuses to roll them
|
||||
back — after which `rollback()` refuses and `writeLandedFile` refuses. (Destroying
|
||||
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.
|
||||
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
|
||||
@@ -77,6 +85,8 @@ belong to the verbs.
|
||||
- `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_pickers` — `pickPackageForImport` and `pickPackageSavePath`, both `GetUserFileName`. Compile-only until the verbs land; nothing here can be exercised in a unit test.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -9,6 +9,14 @@ reasampler_test(package_io LINK package_io)
|
||||
reasampler_pure_library(package_rollback SOURCES package_rollback.cpp LINK PUBLIC package_io)
|
||||
reasampler_test(package_rollback LINK package_rollback)
|
||||
|
||||
# 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
|
||||
# library so the TU stays compiled. reaper_plugin.h pulls SWELL in on non-Windows.
|
||||
add_library(package_pickers STATIC package_pickers.cpp)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.renamedCount = plan.renameCount;
|
||||
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 bool applied = applyImportedBank(
|
||||
session.book(), mintBankId(), 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;
|
||||
}
|
||||
|
||||
// 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,38 @@
|
||||
#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 bankDisplayName; // the bank actually created
|
||||
std::string seedBankName; // what the package asked to be called
|
||||
bool bankNameAdjusted = false;
|
||||
|
||||
int landedCount = 0;
|
||||
int renamedCount = 0;
|
||||
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,137 @@
|
||||
// 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 <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) {
|
||||
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;
|
||||
index->add(e.sample);
|
||||
// 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,64 @@
|
||||
#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 — the two refuse-whole failures (TooNew, Malformed) before a byte is
|
||||
// written, the other two after a rollback.
|
||||
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
|
||||
WriteFailed, // a write failed partway; the landed files were rolled back
|
||||
IndexRejected, // the book refused the bank the plan minted a free name for
|
||||
};
|
||||
|
||||
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; // IntegrityFailed / WriteFailed
|
||||
};
|
||||
|
||||
// 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
|
||||
|
||||
- `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_render` — the WM_PAINT draw.
|
||||
- `panel_input` — click/wheel/keyboard routing + the new-content auto-tag timer.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_bank_ops.h"
|
||||
|
||||
#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/persist/session.h" // ReaSamplerSession — the live session the ops mutate
|
||||
|
||||
@@ -242,6 +243,7 @@ enum : unsigned int {
|
||||
kMenuEvacuate,
|
||||
kMenuCreate,
|
||||
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
|
||||
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
||||
};
|
||||
@@ -267,6 +269,7 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
menuAppend(menu, kMenuDelete, "Delete...");
|
||||
menuSeparator(menu);
|
||||
menuAppend(menu, kMenuCreate, "New bank...");
|
||||
menuAppend(menu, kMenuImportPackage, "Import bank package...");
|
||||
|
||||
const int cmd = TrackPopupMenu(menu, TPM_RETURNCMD, screenX, screenY, 0,
|
||||
g_panel.hwnd, nullptr);
|
||||
@@ -278,6 +281,11 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
case kMenuEvacuate: doEvacuateBank(bankId); break;
|
||||
case kMenuDelete: doDeleteBank(bankId); 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) doImportBankPackage(*g_panel.session);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
#include "shell/panel/draw_kit.h"
|
||||
#include "shell/actions/ingest.h"
|
||||
#include "shell/actions/package_import_action.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
||||
@@ -40,12 +41,24 @@ PanelState g_panel;
|
||||
|
||||
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
|
||||
// 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
|
||||
// the active bank (bank-fill only — no assignment to any live instance).
|
||||
// always frees the shell-allocated drop buffer. A .rsbank is a whole bank, not audio,
|
||||
// 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) {
|
||||
std::vector<std::string> paths;
|
||||
std::vector<std::string> packages;
|
||||
const UINT count = DragQueryFile(hDrop, 0xFFFFFFFF, nullptr, 0);
|
||||
paths.reserve(count);
|
||||
for (UINT i = 0; i < count; ++i) {
|
||||
@@ -54,9 +67,14 @@ void handleDropFiles(HDROP hDrop) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(len) + 1, '\0');
|
||||
DragQueryFile(hDrop, i, buf.data(), static_cast<UINT>(buf.size()));
|
||||
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);
|
||||
if (g_panel.session)
|
||||
for (const std::string& pkg : packages)
|
||||
doImportBankPackageFile(*g_panel.session, pkg);
|
||||
if (!paths.empty()) ingestDroppedFiles(paths);
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ public:
|
||||
// treatment.
|
||||
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.
|
||||
tracking::LedgerStatus ledgerStatus() const { return trackingStatus_; }
|
||||
|
||||
// The version that last wrote the active project: PreVersioning (no
|
||||
// stamp), Unknown (malformed), or Stamped.
|
||||
const version::WritingVersion& writingVersion() const { return writingVersion_; }
|
||||
|
||||
Reference in New Issue
Block a user