feat(persist): owned-file manifest seam — capture records created files (B-cap)
Pure OwnedFileManifest (relative paths, dedup, JSON round-trip) persisted under sibling owned_files ext-state key; both capture commit paths record; joins the R-B undo-reload set. Phase R prune consumes it later.
This commit is contained in:
+18
-1
@@ -157,6 +157,18 @@ add_library(bank_book STATIC src/bank_book.cpp)
|
||||
target_include_directories(bank_book PUBLIC src)
|
||||
target_link_libraries(bank_book PUBLIC bank_model)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g'') Pure owned_manifest library — NO REAPER, NO SWELL. The owned-file manifest
|
||||
# seam (Phase B B-cap): the set of project-relative files the capture path
|
||||
# itself created, so Phase R prune can tell the bank system's own orphans from
|
||||
# hand-dropped files. Deliberately DECOUPLED from bank_book — it tracks files
|
||||
# CREATED, not index membership (sample-remove is not manifest-remove). Small
|
||||
# pure type + JSON round-trip; mirror of wav_trim / tab_strip. B-cap writes +
|
||||
# persists it; Phase R (R1/R2) consumes it — no prune logic here.
|
||||
# ---------------------------------------------------------------------------
|
||||
add_library(owned_manifest STATIC src/owned_manifest.cpp)
|
||||
target_include_directories(owned_manifest PUBLIC src)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2g) Pure realtime_record library — NO REAPER, NO SWELL. The M8 realtime-record
|
||||
# logic: capture scope + FX-tap point -> I_RECMODE / I_RECMODE_FLAGS values,
|
||||
@@ -251,6 +263,10 @@ add_executable(wav_trim_tests tests/test_wav_trim.cpp)
|
||||
target_link_libraries(wav_trim_tests PRIVATE wav_trim)
|
||||
add_test(NAME wav_trim_tests COMMAND wav_trim_tests)
|
||||
|
||||
add_executable(owned_manifest_tests tests/test_owned_manifest.cpp)
|
||||
target_link_libraries(owned_manifest_tests PRIVATE owned_manifest)
|
||||
add_test(NAME owned_manifest_tests COMMAND owned_manifest_tests)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -286,8 +302,9 @@ add_library(reaper_reasampler MODULE
|
||||
src/item_read.cpp
|
||||
src/actions.cpp
|
||||
src/bank_book.cpp
|
||||
src/owned_manifest.cpp
|
||||
)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim)
|
||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings tail_control realtime_record bank_book wav_trim owned_manifest)
|
||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||
|
||||
|
||||
+15
-5
@@ -145,7 +145,12 @@ static void CommitRealtimeResult(const reasampler::CaptureResult& res)
|
||||
return;
|
||||
}
|
||||
reasampler::AddResult added = g_session.bank().add(res.sample);
|
||||
g_session.saveToActiveProject(); // persist + MarkProjectDirty (travels with .rpp)
|
||||
// B-cap: record the file the capture created in the owned-file manifest, at the same
|
||||
// point the Sample is added and before the same persist. Recorded regardless of the
|
||||
// index AddResult — even a hash-collapse still WROTE a file the tool owns, and the
|
||||
// manifest dedups a repeat path itself (Phase R prune reconciles manifest vs index).
|
||||
g_session.owned().add(res.sample.relativePath);
|
||||
g_session.saveToActiveProject(); // persist book + manifest + MarkProjectDirty (travels with .rpp)
|
||||
|
||||
std::string log = "ReaSampler: " + res.message + "\n";
|
||||
log += " bank size now " + std::to_string(g_session.bank().size()) +
|
||||
@@ -615,10 +620,15 @@ static void RunCapture(const reasampler::CaptureActionDef& def)
|
||||
|
||||
// Add to the ACTIVE bank: g_session.bank() resolves to book.activeIndex() (B2).
|
||||
reasampler::AddResult added = g_session.bank().add(res.sample);
|
||||
// Persist the updated book into the active project's ext state (the `banks` key)
|
||||
// so the capture survives Save / close+reopen (M4) and travels with the .rpp.
|
||||
// saveToActiveProject also clears the retired legacy key and calls MarkProjectDirty.
|
||||
// Non-destructive: writes only our own ext-state keys.
|
||||
// B-cap: record the created file in the owned-file manifest, at the same point the
|
||||
// Sample is added and before the same persist. Recorded regardless of the index
|
||||
// AddResult — even a hash-collapse still WROTE a file the tool owns, and the manifest
|
||||
// dedups a repeat path itself (Phase R prune reconciles manifest vs index later).
|
||||
g_session.owned().add(res.sample.relativePath);
|
||||
// Persist the updated book AND manifest into the active project's ext state (the
|
||||
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
|
||||
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
|
||||
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
|
||||
g_session.saveToActiveProject();
|
||||
|
||||
std::string log = "ReaSampler: " + res.message + "\n";
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
#include "owned_manifest.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
|
||||
// owned_manifest implementation.
|
||||
//
|
||||
// JSON is hand-rolled and self-contained (project convention: the pure core is
|
||||
// dependency-free — no third-party JSON lib, mirror of bank_model / bank_book /
|
||||
// tail_control). The shape is a single object with one string array:
|
||||
//
|
||||
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
|
||||
//
|
||||
// so a compact writer + a focused string-array parser is all it needs — far smaller
|
||||
// than bank_model's full recursive-descent parser, because there is exactly one key
|
||||
// and one value kind.
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// path invariant (mirror of bank_model's isAbsolutePath)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
|
||||
// incl. drive-relative "C:foo") is absolute. Same rejection bank_model applies to
|
||||
// Sample.relativePath — the manifest holds the SAME kind of path, so the invariant
|
||||
// must match exactly (a path the index accepts must be recordable, and vice versa).
|
||||
bool isAbsolutePath(const std::string& p) {
|
||||
if (p.empty()) return false;
|
||||
if (p[0] == '/' || p[0] == '\\') return true;
|
||||
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mutation / query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
|
||||
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
|
||||
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
|
||||
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
|
||||
paths_.push_back(relativePath);
|
||||
return ManifestAddResult::Added;
|
||||
}
|
||||
|
||||
bool OwnedFileManifest::contains(const std::string& relativePath) const {
|
||||
for (const auto& p : paths_)
|
||||
if (p == relativePath) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON writer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
void writeEscaped(std::string& out, const std::string& s) {
|
||||
out += '"';
|
||||
for (char c : s) {
|
||||
switch (c) {
|
||||
case '"': out += "\\\""; break;
|
||||
case '\\': out += "\\\\"; break;
|
||||
case '\b': out += "\\b"; break;
|
||||
case '\f': out += "\\f"; break;
|
||||
case '\n': out += "\\n"; break;
|
||||
case '\r': out += "\\r"; break;
|
||||
case '\t': out += "\\t"; break;
|
||||
default:
|
||||
if (static_cast<unsigned char>(c) < 0x20) {
|
||||
char buf[8];
|
||||
std::snprintf(buf, sizeof(buf), "\\u%04x",
|
||||
static_cast<unsigned char>(c));
|
||||
out += buf;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
out += '"';
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string OwnedFileManifest::serialize() const {
|
||||
std::string out = "{\"owned\":[";
|
||||
for (std::size_t i = 0; i < paths_.size(); ++i) {
|
||||
if (i) out += ',';
|
||||
writeEscaped(out, paths_[i]);
|
||||
}
|
||||
out += "]}";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON parser (string-array only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
namespace {
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
explicit Parser(const std::string& s) : s_(s) {}
|
||||
|
||||
// Parse the manifest object into `out`. Tolerates unknown keys (forward-compat)
|
||||
// and requires the "owned" value to be an array of strings.
|
||||
bool parseManifest(OwnedFileManifest& out);
|
||||
|
||||
private:
|
||||
const std::string& s_;
|
||||
std::size_t pos_ = 0;
|
||||
|
||||
bool eof() const { return pos_ >= s_.size(); }
|
||||
|
||||
void skipWs() {
|
||||
while (!eof()) {
|
||||
char c = s_[pos_];
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++pos_;
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
bool consume(char c) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != c) return false;
|
||||
++pos_;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseString(std::string& out);
|
||||
bool parseStringArray(std::vector<std::string>& out);
|
||||
bool skipValue(); // for forward-compat unknown keys
|
||||
};
|
||||
|
||||
// Parses a JSON string literal (with the escapes our writer emits, plus \uXXXX for
|
||||
// control chars). Positioned before the opening quote (skips leading whitespace).
|
||||
bool Parser::parseString(std::string& out) {
|
||||
skipWs();
|
||||
if (eof() || s_[pos_] != '"') return false;
|
||||
++pos_;
|
||||
out.clear();
|
||||
while (!eof()) {
|
||||
char c = s_[pos_++];
|
||||
if (c == '"') return true;
|
||||
if (c == '\\') {
|
||||
if (eof()) return false;
|
||||
char e = s_[pos_++];
|
||||
switch (e) {
|
||||
case '"': out += '"'; break;
|
||||
case '\\': out += '\\'; break;
|
||||
case '/': out += '/'; break;
|
||||
case 'b': out += '\b'; break;
|
||||
case 'f': out += '\f'; break;
|
||||
case 'n': out += '\n'; break;
|
||||
case 'r': out += '\r'; break;
|
||||
case 't': out += '\t'; break;
|
||||
case 'u': {
|
||||
auto readHex4 = [&](unsigned int& cp) -> bool {
|
||||
if (pos_ + 4 > s_.size()) return false;
|
||||
cp = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
char h = s_[pos_++];
|
||||
cp <<= 4;
|
||||
if (h >= '0' && h <= '9') cp |= static_cast<unsigned>(h - '0');
|
||||
else if (h >= 'a' && h <= 'f') cp |= static_cast<unsigned>(h - 'a' + 10);
|
||||
else if (h >= 'A' && h <= 'F') cp |= static_cast<unsigned>(h - 'A' + 10);
|
||||
else return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
unsigned int hi = 0;
|
||||
if (!readHex4(hi)) return false;
|
||||
|
||||
unsigned int codePoint = hi;
|
||||
if (hi >= 0xD800 && hi <= 0xDBFF) {
|
||||
if (pos_ + 6 > s_.size()) return false;
|
||||
if (s_[pos_] != '\\' || s_[pos_ + 1] != 'u') return false;
|
||||
pos_ += 2;
|
||||
unsigned int lo = 0;
|
||||
if (!readHex4(lo)) return false;
|
||||
if (lo < 0xDC00 || lo > 0xDFFF) return false;
|
||||
codePoint = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00);
|
||||
} else if (hi >= 0xDC00 && hi <= 0xDFFF) {
|
||||
return false; // unpaired low surrogate
|
||||
}
|
||||
|
||||
if (codePoint <= 0x7F) {
|
||||
out += static_cast<char>(codePoint);
|
||||
} else if (codePoint <= 0x7FF) {
|
||||
out += static_cast<char>(0xC0 | (codePoint >> 6));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
} else if (codePoint <= 0xFFFF) {
|
||||
out += static_cast<char>(0xE0 | (codePoint >> 12));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (codePoint >> 18));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((codePoint >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (codePoint & 0x3F));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
return false; // unterminated string
|
||||
}
|
||||
|
||||
bool Parser::parseStringArray(std::vector<std::string>& out) {
|
||||
if (!consume('[')) return false;
|
||||
skipWs();
|
||||
if (consume(']')) return true; // empty array
|
||||
for (;;) {
|
||||
std::string s;
|
||||
if (!parseString(s)) return false;
|
||||
out.push_back(std::move(s));
|
||||
skipWs();
|
||||
if (consume(',')) continue;
|
||||
if (consume(']')) return true;
|
||||
return false; // neither separator nor terminator — malformed
|
||||
}
|
||||
}
|
||||
|
||||
// Skip a single JSON value (string / array / object / bare scalar) so an unknown key
|
||||
// does not abort the parse. Minimal: enough for forward-compat siblings we don't know.
|
||||
bool Parser::skipValue() {
|
||||
skipWs();
|
||||
if (eof()) return false;
|
||||
char c = s_[pos_];
|
||||
if (c == '"') {
|
||||
std::string tmp;
|
||||
return parseString(tmp);
|
||||
}
|
||||
if (c == '[' || c == '{') {
|
||||
// Balance nested brackets of either kind, ignoring bracket chars inside
|
||||
// strings. Enough to step over an unknown nested value; not a full validator.
|
||||
int depth = 0;
|
||||
bool inStr = false;
|
||||
while (!eof()) {
|
||||
char d = s_[pos_];
|
||||
if (inStr) {
|
||||
if (d == '\\') { pos_ += 2; continue; }
|
||||
if (d == '"') inStr = false;
|
||||
++pos_;
|
||||
continue;
|
||||
}
|
||||
if (d == '"') { inStr = true; ++pos_; continue; }
|
||||
if (d == '[' || d == '{') ++depth;
|
||||
else if (d == ']' || d == '}') {
|
||||
--depth;
|
||||
if (depth == 0) { ++pos_; return true; }
|
||||
}
|
||||
++pos_;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// bare scalar (number / true / false / null) — read to the next structural char
|
||||
while (!eof()) {
|
||||
char d = s_[pos_];
|
||||
if (d == ',' || d == '}' || d == ']' || d == ' ' || d == '\t' ||
|
||||
d == '\n' || d == '\r')
|
||||
break;
|
||||
++pos_;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Parser::parseManifest(OwnedFileManifest& out) {
|
||||
if (!consume('{')) return false;
|
||||
skipWs();
|
||||
if (consume('}')) return true; // empty object -> empty manifest
|
||||
for (;;) {
|
||||
std::string key;
|
||||
if (!parseString(key)) return false;
|
||||
if (!consume(':')) return false;
|
||||
if (key == "owned") {
|
||||
std::vector<std::string> paths;
|
||||
if (!parseStringArray(paths)) return false;
|
||||
for (auto& p : paths) {
|
||||
// Feed through add() so the persisted invariants (dedup, reject
|
||||
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
|
||||
// blob cannot smuggle an absolute or duplicate path into the manifest.
|
||||
out.add(p);
|
||||
}
|
||||
} else {
|
||||
if (!skipValue()) return false; // forward-compat: tolerate unknown keys
|
||||
}
|
||||
skipWs();
|
||||
if (consume(',')) continue;
|
||||
if (consume('}')) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& json) {
|
||||
OwnedFileManifest m;
|
||||
Parser p(json);
|
||||
if (!p.parseManifest(m)) return std::nullopt;
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
// owned_manifest — the pure core of the owned-file manifest seam (Phase B, B-cap).
|
||||
//
|
||||
// PURE MODULE (CLAUDE.md §load-bearing split): NO REAPER types, NO SWELL, NO
|
||||
// vendor/ includes. Standard library only. Unit-tested outside the DAW — the same
|
||||
// "small pure type + JSON round-trip" pattern as wav_trim / tab_strip.
|
||||
//
|
||||
// -- What it is --------------------------------------------------------------
|
||||
//
|
||||
// The set of files the bank system ITSELF created — every file the capture path
|
||||
// writes gets recorded here. Phase R prune consumes it to tell the system's own
|
||||
// orphans (owned ∩ present − referenced) apart from hand-dropped files. B-cap only
|
||||
// WRITES and PERSISTS the manifest; no prune logic lives here (fork R-D, settled
|
||||
// 2026-07-24: "defer the feature, design the seam").
|
||||
//
|
||||
// -- What it is NOT ----------------------------------------------------------
|
||||
//
|
||||
// It is NOT a mirror of the bank index. Removing or moving an index entry does NOT
|
||||
// remove the file's manifest record: the manifest tracks files *created*, and prune
|
||||
// (Phase R) reconciles manifest-vs-index later. The ONLY thing that adds to it is
|
||||
// the capture add-path. There is deliberately no remove verb here.
|
||||
//
|
||||
// -- The relative-paths-only invariant ---------------------------------------
|
||||
//
|
||||
// A manifest path is ALWAYS project-relative (same invariant as Sample.relativePath
|
||||
// and the persisted BankIndex). add() rejects an absolute path rather than guess a
|
||||
// relativization — the pure model has no project root, so a "normalization" would be
|
||||
// a guess that could point at the wrong file (mirror of BankIndex::add's rejection).
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// Outcome of an add(). Mirrors BankIndex::AddResult's honesty — the op reports what
|
||||
// happened rather than silently mutating on a bad request.
|
||||
// - Added: the path was new and recorded.
|
||||
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
|
||||
// - RejectedEmptyPath: the path was empty.
|
||||
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
|
||||
enum class ManifestAddResult {
|
||||
Added,
|
||||
AlreadyPresent,
|
||||
RejectedEmptyPath,
|
||||
RejectedAbsolutePath,
|
||||
};
|
||||
|
||||
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
|
||||
// paths the capture path has created. Insertion order is preserved so serialize()
|
||||
// round-trips byte-identically (deterministic ext-state, mirror of the index).
|
||||
class OwnedFileManifest {
|
||||
public:
|
||||
OwnedFileManifest() = default;
|
||||
|
||||
// Record a project-relative path as owned. Rejects an empty or absolute path (no
|
||||
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
|
||||
// capture of an identical request does not double-record.
|
||||
ManifestAddResult add(const std::string& relativePath);
|
||||
|
||||
// True iff the exact path string is recorded. Phase R uses this to attribute a
|
||||
// present file to the bank system. Exact string match — path normalization (if any)
|
||||
// is the caller's concern, consistent across add and query.
|
||||
bool contains(const std::string& relativePath) const;
|
||||
|
||||
// The owned paths in insertion order. Phase R unions this with the on-disk file
|
||||
// set; here it is the round-trip + query surface.
|
||||
const std::vector<std::string>& paths() const { return paths_; }
|
||||
|
||||
std::size_t size() const { return paths_.size(); }
|
||||
bool empty() const { return paths_.empty(); }
|
||||
|
||||
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
|
||||
|
||||
// -- Persistence ---------------------------------------------------------
|
||||
|
||||
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
|
||||
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
|
||||
std::string serialize() const;
|
||||
|
||||
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
|
||||
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
|
||||
// view malformed handling). An empty/absent stored value is the caller's concern
|
||||
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
|
||||
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
|
||||
|
||||
private:
|
||||
std::vector<std::string> paths_; // insertion order; deduplicated
|
||||
};
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -217,6 +217,15 @@ bool ReaSamplerSession::saveToActiveProject() {
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtTailKey, tailJson.c_str());
|
||||
|
||||
// Additive: the owned-file manifest (Phase B B-cap) rides alongside in its own
|
||||
// `owned_files` key. Independent write — does not disturb the blobs above. Written
|
||||
// on EVERY save so a capture's manifest record survives Save / Save-As / reopen,
|
||||
// and so the manifest and the bank stay in lockstep on disk (both persisted by the
|
||||
// same saveToActiveProject the capture add-path calls).
|
||||
const std::string ownedJson = owned_.serialize();
|
||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||
kProjExtOwnedKey, ownedJson.c_str());
|
||||
|
||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||
return true;
|
||||
}
|
||||
@@ -259,6 +268,25 @@ TailSetting loadTailSetting(ReaProject* proj) {
|
||||
return *loaded;
|
||||
}
|
||||
|
||||
// Load the owned-file manifest from a project's owned_files key, or return an empty
|
||||
// manifest. An absent/empty key (older / never-captured project) yields an empty
|
||||
// manifest — graceful, never a crash. Malformed JSON is warned and also falls back to
|
||||
// empty, mirroring the bank's / view's / tail's malformed handling. Phase R prune then
|
||||
// sees an empty ownership record and (safely) attributes nothing until the next capture
|
||||
// rebuilds it — losing the record degrades safety, never correctness.
|
||||
OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
|
||||
if (!proj) return OwnedFileManifest{};
|
||||
const std::string ownedJson =
|
||||
getProjExtStateString(proj, kProjExtNamespace, kProjExtOwnedKey);
|
||||
if (ownedJson.empty()) return OwnedFileManifest{}; // no stored manifest -> empty
|
||||
std::optional<OwnedFileManifest> loaded = OwnedFileManifest::deserialize(ownedJson);
|
||||
if (!loaded) {
|
||||
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed — ignoring.\n");
|
||||
return OwnedFileManifest{};
|
||||
}
|
||||
return std::move(*loaded);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
||||
@@ -281,6 +309,12 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
|
||||
// the previous project's choice (this REPLACES the old session-carry behavior).
|
||||
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
|
||||
|
||||
// The owned-file manifest is restored on EVERY load path too (peer-symmetry with the
|
||||
// bank/view/tail resets): switching to a project with no stored manifest must reset
|
||||
// to empty, not inherit the previous project's ownership record; an undo/redo reload
|
||||
// (R-B) must re-read the restored manifest so it matches the rolled-back bank state.
|
||||
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
|
||||
|
||||
if (!proj) {
|
||||
book_ = BankBook{};
|
||||
return;
|
||||
|
||||
+30
-1
@@ -21,6 +21,7 @@
|
||||
|
||||
#include "bank_book.h"
|
||||
#include "bank_model.h"
|
||||
#include "owned_manifest.h"
|
||||
#include "tail_control.h"
|
||||
#include "view_mode_model.h"
|
||||
|
||||
@@ -58,6 +59,17 @@ inline constexpr const char* kProjExtViewKey = "view_state";
|
||||
// default — graceful, but the user's saved choice would be lost).
|
||||
inline constexpr const char* kProjExtTailKey = "tail_setting";
|
||||
|
||||
// The ext-state key holding the owned-file manifest JSON (the set of project-relative
|
||||
// files the capture path itself created — Phase B B-cap seam, consumed by Phase R
|
||||
// prune to distinguish the bank system's own orphans from hand-dropped files). A
|
||||
// SIBLING key alongside banks/view_state/tail_setting — NOT folded into the `banks`
|
||||
// blob, so it stays decoupled from bank membership (removing an index entry is not a
|
||||
// manifest removal). One namespace, four content keys. FOREVER-STABLE: changing it
|
||||
// strands every already-saved project's ownership record, so Phase R prune could no
|
||||
// longer tell the tool's own files apart (it would fall back to an empty manifest —
|
||||
// graceful, but the attribution safety net is lost until the next capture rebuilds it).
|
||||
inline constexpr const char* kProjExtOwnedKey = "owned_files";
|
||||
|
||||
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
|
||||
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
|
||||
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
|
||||
@@ -122,6 +134,15 @@ public:
|
||||
TailSetting& tail() { return tail_; }
|
||||
const TailSetting& tail() const { return tail_; }
|
||||
|
||||
// The owned-file manifest (Phase B B-cap): the set of project-relative files the
|
||||
// capture path itself created. The capture add-path records each created file here
|
||||
// (main.cpp, alongside the bank add), exactly as it adds the Sample to the active
|
||||
// bank; persist serializes it under the `owned_files` key on save and replaces it on
|
||||
// project load / undo-reload — peer to book_/view_/tail_. Phase R prune CONSUMES it;
|
||||
// B-cap only writes and persists it (no prune logic here).
|
||||
OwnedFileManifest& owned() { return owned_; }
|
||||
const OwnedFileManifest& owned() const { return owned_; }
|
||||
|
||||
// Serialize the current book (under the `banks` key), view model, and tail setting
|
||||
// to the active project's ext state (namespace "reasampler"), and clear the retired
|
||||
// legacy `bank_index` key. Non-destructive beyond writing our own ext-state keys.
|
||||
@@ -180,6 +201,13 @@ private:
|
||||
// adjusted project), so an absent key is graceful. Peer to bank_/view_.
|
||||
TailSetting tail_;
|
||||
|
||||
// The owned-file manifest. Default empty; loadFromProject resets it to empty (or the
|
||||
// stored set) on EVERY load path (peer-symmetry with book_/view_/tail_): switching to
|
||||
// a project with no stored manifest must not inherit the previous project's ownership
|
||||
// record, and an undo that rolled back a capture must re-read the restored manifest so
|
||||
// the in-memory set matches disk. Absent key -> empty is graceful (older project).
|
||||
OwnedFileManifest owned_;
|
||||
|
||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||
// The GUID is the PRIMARY signal (a different stored GUID = a different project
|
||||
// of record = Load, immune to pointer recycling). The pointer disambiguates the
|
||||
@@ -197,7 +225,8 @@ private:
|
||||
|
||||
// Load the book from the given project's ext state (the `banks` key, else the
|
||||
// legacy `bank_index` key migrated into the pool) and resolve bank paths against
|
||||
// projectDir at read time. Replaces the in-memory book. projectDir empty -> the
|
||||
// projectDir at read time. Replaces the in-memory book. Also restores view_, tail_,
|
||||
// and owned_ from their sibling keys on every load path. projectDir empty -> the
|
||||
// book is reset to empty (unsaved project has no resolvable banks).
|
||||
void loadFromProject(void* proj, const std::string& projectDir);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework.
|
||||
// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered
|
||||
// set of project-relative files the capture path created, with JSON round-trip.
|
||||
//
|
||||
// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest.
|
||||
// Plus: the relative-paths-only invariant (reject empty / absolute), contains()
|
||||
// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist
|
||||
// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters.
|
||||
|
||||
#include "../src/owned_manifest.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- empty manifest ----------------------------------------------------------
|
||||
|
||||
static void testEmptyManifest() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.empty());
|
||||
CHECK(m.size() == 0);
|
||||
CHECK(m.paths().empty());
|
||||
CHECK(!m.contains("anything.wav"));
|
||||
|
||||
// An empty manifest serializes to a well-formed shape and round-trips to empty.
|
||||
const std::string json = m.serialize();
|
||||
auto back = OwnedFileManifest::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
CHECK(back->empty());
|
||||
}
|
||||
|
||||
// --- add / contains / order --------------------------------------------------
|
||||
|
||||
static void testAddAndContains() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added);
|
||||
CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added);
|
||||
|
||||
CHECK(m.size() == 2);
|
||||
CHECK(m.contains("reasampler_bank/a.wav"));
|
||||
CHECK(m.contains("reasampler_bank/b.wav"));
|
||||
CHECK(!m.contains("reasampler_bank/c.wav"));
|
||||
// Exact-string match — not a prefix / substring match.
|
||||
CHECK(!m.contains("reasampler_bank/a"));
|
||||
CHECK(!m.contains("a.wav"));
|
||||
|
||||
// Insertion order is preserved (deterministic ext-state).
|
||||
CHECK(m.paths().size() == 2);
|
||||
CHECK(m.paths()[0] == "reasampler_bank/a.wav");
|
||||
CHECK(m.paths()[1] == "reasampler_bank/b.wav");
|
||||
}
|
||||
|
||||
// --- dedup of repeated adds --------------------------------------------------
|
||||
|
||||
static void testDedupRepeatedAdds() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added);
|
||||
// A repeat capture of an identical request must not double-record the file.
|
||||
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
|
||||
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
|
||||
CHECK(m.size() == 1);
|
||||
CHECK(m.paths().size() == 1);
|
||||
}
|
||||
|
||||
// --- relative-paths-only invariant -------------------------------------------
|
||||
|
||||
static void testRejectsEmptyAndAbsolute() {
|
||||
OwnedFileManifest m;
|
||||
CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath);
|
||||
// Every absolute form bank_model rejects, the manifest rejects too.
|
||||
CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root
|
||||
CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */
|
||||
CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive /
|
||||
CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */
|
||||
CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative
|
||||
// A rejected add never mutates.
|
||||
CHECK(m.empty());
|
||||
CHECK(!m.contains("/abs/take.wav"));
|
||||
}
|
||||
|
||||
// --- JSON round-trip ---------------------------------------------------------
|
||||
|
||||
static void testRoundTrip() {
|
||||
OwnedFileManifest m;
|
||||
m.add("reasampler_bank/one.wav");
|
||||
m.add("reasampler_bank/two.wav");
|
||||
m.add("reasampler_bank/three.wav");
|
||||
|
||||
const std::string json = m.serialize();
|
||||
auto back = OwnedFileManifest::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
// Order + membership survive.
|
||||
CHECK(back->paths().size() == 3);
|
||||
CHECK(back->paths()[0] == "reasampler_bank/one.wav");
|
||||
CHECK(back->paths()[2] == "reasampler_bank/three.wav");
|
||||
|
||||
// serialize(deserialize(serialize(x))) is stable.
|
||||
CHECK(back->serialize() == json);
|
||||
}
|
||||
|
||||
// A path carrying JSON metacharacters must survive the escape/unescape round-trip.
|
||||
static void testRoundTripEscaping() {
|
||||
OwnedFileManifest m;
|
||||
m.add("reasampler_bank/od\"d name.wav"); // embedded quote
|
||||
m.add("reasampler_bank/back\\slash.wav"); // embedded backslash
|
||||
m.add("reasampler_bank/tab\tafter.wav"); // control char
|
||||
|
||||
auto back = OwnedFileManifest::deserialize(m.serialize());
|
||||
CHECK(back.has_value());
|
||||
CHECK(*back == m);
|
||||
CHECK(back->contains("reasampler_bank/od\"d name.wav"));
|
||||
CHECK(back->contains("reasampler_bank/back\\slash.wav"));
|
||||
CHECK(back->contains("reasampler_bank/tab\tafter.wav"));
|
||||
}
|
||||
|
||||
// --- malformed / tolerant parse ----------------------------------------------
|
||||
|
||||
static void testMalformedParse() {
|
||||
// The persist shell's warn+fallback hinges on nullopt for a corrupt blob.
|
||||
CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string
|
||||
CHECK(!OwnedFileManifest::deserialize("not json").has_value());
|
||||
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array
|
||||
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element
|
||||
CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type
|
||||
|
||||
// An explicit empty array parses to an empty manifest.
|
||||
auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}");
|
||||
CHECK(empty.has_value());
|
||||
CHECK(empty->empty());
|
||||
|
||||
// An unknown sibling key is tolerated (forward-compat) — the owned array still loads.
|
||||
auto fwd = OwnedFileManifest::deserialize(
|
||||
"{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}");
|
||||
CHECK(fwd.has_value());
|
||||
CHECK(fwd->size() == 1);
|
||||
CHECK(fwd->contains("reasampler_bank/x.wav"));
|
||||
|
||||
// A stored blob cannot smuggle a duplicate or absolute path past the load-time
|
||||
// invariant re-assertion (deserialize routes each element through add()).
|
||||
auto dupe = OwnedFileManifest::deserialize(
|
||||
"{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}");
|
||||
CHECK(dupe.has_value());
|
||||
CHECK(dupe->size() == 1);
|
||||
|
||||
auto absolute = OwnedFileManifest::deserialize(
|
||||
"{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}");
|
||||
CHECK(absolute.has_value());
|
||||
CHECK(absolute->size() == 1);
|
||||
CHECK(absolute->contains("reasampler_bank/ok.wav"));
|
||||
CHECK(!absolute->contains("/etc/evil.wav"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEmptyManifest();
|
||||
testAddAndContains();
|
||||
testDedupRepeatedAdds();
|
||||
testRejectsEmptyAndAbsolute();
|
||||
testRoundTrip();
|
||||
testRoundTripEscaping();
|
||||
testMalformedParse();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user