7b53ba16f6
Extend BANK_PRUNE_FOLDER from report-only to dry-run→confirm-with-manifest→ delete exactly the pure-core orphan set (fresh recompute, stale entries skipped). Windows routes to Recycle Bin (SHFileOperation+FOF_ALLOWUNDO), else unlink. New pure prune_button module + footer button dispatching the action id. No ext-state write, no undo point (deletion is not REAPER-undoable).
314 lines
20 KiB
C++
314 lines
20 KiB
C++
#pragma once
|
|
// persist — the REAPER-facing bridge between the in-memory BankIndex and project
|
|
// ext state (CLAUDE.md §load-bearing split; CONTEXT.md §Persistence & paths).
|
|
//
|
|
// Save: serialize the BankIndex JSON -> SetProjExtState under namespace
|
|
// "reasampler" (ext state lives inside the .rpp, so the index travels with the
|
|
// project for free).
|
|
// Load: on project load, GetProjExtState -> bank_model::deserialize -> in-memory
|
|
// BankIndex, then resolve each entry's bank file against the CURRENT project
|
|
// dir (project-relative resolution — a project opened from a new location still
|
|
// finds its bank).
|
|
// Save-As: when the project path changes, relocate the physical bank folder so
|
|
// the wavs end up under the new .rpp (the index's relative paths stay valid).
|
|
//
|
|
// The header is REAPER-free (no SDK types leak here): callers interact through a
|
|
// ReaSamplerSession that owns the bank and the persist lifecycle. All REAPER API
|
|
// calls live in persist.cpp. It depends on bank_model (pure) for JSON round-trip
|
|
// and capture_paths (pure) for the path arithmetic it drives.
|
|
|
|
#include <string>
|
|
|
|
#include "app_version.h"
|
|
#include "bank_book.h"
|
|
#include "bank_model.h"
|
|
#include "owned_manifest.h"
|
|
#include "prune_reconcile.h"
|
|
#include "tail_control.h"
|
|
#include "view_mode_model.h"
|
|
|
|
namespace reasampler {
|
|
|
|
// The ext-state namespace every ReaSampler key is stored under. CHANNEL-DERIVED (Phase V,
|
|
// V4): the pure app_version module owns the one channel-qualified string — "reasampler" on
|
|
// stable (byte-identical to the pre-V4 build) or "reasampler_beta" on the isolated beta
|
|
// build. FOREVER-STABLE per channel once shipped: changing either orphans every already-
|
|
// saved project's state. Beta reads/writes ONLY its own namespace — a project saved by
|
|
// stable shows empty/default state in beta and vice versa; that isolation is the accepted
|
|
// V4 safety property (no cross-namespace read, migration, or fallback), not a bug.
|
|
// Returns const char* (not a constexpr literal) because the string is channel-derived at
|
|
// build time; the accessor is the single call point for all persist reads/writes below.
|
|
inline const char* projExtNamespace() { return extStateNamespace().c_str(); }
|
|
|
|
// The RETIRED legacy ext-state key: pre-multi-bank projects stored the whole
|
|
// serialized BankIndex here (single bank). Phase B2 no longer WRITES it — on save
|
|
// the key is cleared (SetProjExtState with "" deletes it) and the book is written
|
|
// under kProjExtBanksKey instead. It is still READ once, on load of a legacy
|
|
// project, to migrate its single index into the pool (BankBook's parse-time
|
|
// promotion). FOREVER-STABLE as a read key for that migration path.
|
|
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
|
|
|
// The multi-bank ext-state key (Phase B): one key holds the whole serialized
|
|
// BankBook — the pool folded in as bank-zero plus every named bank, each with its
|
|
// own BankIndex, ordinals, and the active-bank id. AUTHORITATIVE going forward;
|
|
// supersedes kProjExtIndexKey. FOREVER-STABLE once shipped: changing it orphans
|
|
// every already-saved project's banks.
|
|
inline constexpr const char* kProjExtBanksKey = "banks";
|
|
|
|
// The ext-state key the Design-View ViewModeModel JSON is stored under (one key
|
|
// holds the whole serialized model: modes + membership + show-both + snapshots +
|
|
// active mode). Distinct from kProjExtIndexKey — one namespace, two keys.
|
|
// FOREVER-STABLE: changing it orphans every already-saved project's view state.
|
|
inline constexpr const char* kProjExtViewKey = "view_state";
|
|
|
|
// The ext-state key the docked panel's TailSetting JSON (mode + manualMs) is stored
|
|
// under, so the tail choice travels inside the .rpp and loads per project. Distinct
|
|
// from the index/view keys — one namespace, three keys. FOREVER-STABLE: changing it
|
|
// orphans every already-saved project's tail setting (which then falls back to the
|
|
// 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 the ReaSampler version that last WROTE this project
|
|
// (Phase V, V1). Written on every save alongside the banks/view/tail keys, so every
|
|
// saved .rpp records which build produced its state — the seam a future within-channel
|
|
// forward migration keys off ("this was written by 0.9.01, I am 0.9.05"). An absent
|
|
// key is the explicit pre-versioning case (a project saved before this shipped), read
|
|
// silently, never an error. FOREVER-STABLE key string once shipped.
|
|
inline constexpr const char* kProjExtVersionKey = "version";
|
|
|
|
// 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
|
|
// onto a recycled ReaProject* pointer (different GUID). FOREVER-STABLE: changing
|
|
// it strands the identity of every already-saved project. See persist.cpp.
|
|
inline constexpr const char* kProjExtGuidKey = "project_guid";
|
|
|
|
// Owns the session's BankBook (Phase B: pool + named banks) and drives persistence
|
|
// against the active REAPER project. One instance lives for the extension's
|
|
// lifetime (main.cpp). It tracks
|
|
// the project identity it last saw so the timer tick can detect a project load
|
|
// (a different project became active) and a Save-As (SAME project, path changed):
|
|
//
|
|
// * project load -> load the index from ext state, resolve bank paths
|
|
// * Save-As (new dir) -> relocate the bank folder under the new .rpp
|
|
//
|
|
// Identity is layered GUID-PRIMARY: the minted GUID (content-based identity of
|
|
// record, immune to REAPER recycling a closed project's ReaProject* address) is
|
|
// checked FIRST, and the live pointer disambiguates only the same-GUID case — a
|
|
// forked sibling (same GUID, different object -> Load) vs a genuine Save-As (same
|
|
// GUID, same object, new path -> relocate). GUID-first catches pointer recycling
|
|
// (a reopened/new project reusing the previous address with a different GUID — the
|
|
// W12 defect that stopped the bank reloading); the pointer catches forks (Save-As
|
|
// copies our GUID onto a distinct object — the W10 defect that clobbered a bank).
|
|
//
|
|
// The book itself is exposed for the capture/action layer to mutate; persist
|
|
// only reads it on save and replaces it on load.
|
|
class ReaSamplerSession {
|
|
public:
|
|
ReaSamplerSession() = default;
|
|
|
|
// The multi-bank book (Phase B): the pool + named banks, each wrapping a
|
|
// BankIndex, plus the active-bank id. The action layer (B3) creates / renames /
|
|
// reorders / deletes banks and moves samples here; the panel (B4) reads it;
|
|
// persist serializes it under the `banks` key on save and replaces it on load.
|
|
BankBook& book() { return book_; }
|
|
const BankBook& book() const { return book_; }
|
|
|
|
// The capture add-target: the ACTIVE bank's BankIndex (defaults to the pool).
|
|
// The capture path adds a captured Sample through this seam, so a capture lands
|
|
// in whichever bank is active — the single behavioural change B2 wires in over
|
|
// M7/M8 (the capture backends are untouched; only the target index moved). The
|
|
// panel/insert readers that displayed the single index continue to read it here
|
|
// unchanged; today it resolves to the pool (default active), matching prior
|
|
// single-bank behaviour, until B3/B4 let the user switch the active bank.
|
|
BankIndex& bank() { return book_.activeIndex(); }
|
|
const BankIndex& bank() const { return book_.activeIndex(); }
|
|
|
|
// The in-memory Design-View model. The view/action layer mutates it (tag,
|
|
// toggle, snapshot); persist serializes it on save and replaces it on project
|
|
// load — exactly as it treats the bank. D3 persists MODEL STATE only; applying
|
|
// visibility/processing (reapply-on-open) is D4's job, not this member's.
|
|
ViewModeModel& view() { return view_; }
|
|
const ViewModeModel& view() const { return view_; }
|
|
|
|
// The docked panel's tail setting (mode + manualMs), authoritative here — NOT in
|
|
// panel state — so it travels inside the .rpp: persist serializes it on save and
|
|
// replaces it on project load exactly as it treats the bank and view model. The
|
|
// panel reads/writes it through this seam (bank_panel holds the session), and the
|
|
// capture actions read it via bankPanelTailSetting. Default None / 2 s manual for
|
|
// an unsaved or pre-feature project (no stored key -> this default survives load).
|
|
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_; }
|
|
|
|
// The ReaSampler version that last WROTE the active project, recovered from its
|
|
// ext-state stamp on load (Phase V, V1). PreVersioning when the project carries no
|
|
// stamp (saved before this feature), Unknown for a malformed stamp, Stamped with the
|
|
// exact stored string otherwise — all silent, never an error. Replaced on every load
|
|
// path (peer-symmetry with bank_/view_/tail_); default PreVersioning for an unsaved
|
|
// or never-loaded session. Exposed so a future migration step (or diagnostics) can
|
|
// reason about the origin build without re-reading ext state.
|
|
const WritingVersion& writingVersion() const { return writingVersion_; }
|
|
|
|
// 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.
|
|
// Safe to call when there is no active/saved project (it no-ops).
|
|
//
|
|
// Returns true iff a persist actually happened (an active, SAVED project existed);
|
|
// false when it no-op'd (no active project, or an unsaved one with no .rpp). Lets a
|
|
// caller wrapping this in an undo block skip the block when nothing was written, so
|
|
// no dangling no-effect undo entry is opened on an unsaved project.
|
|
bool saveToActiveProject();
|
|
|
|
// Compute the Phase R prune dry-run for the ACTIVE project (Wave 2 — REPORT ONLY,
|
|
// deletes nothing). Enumerates the resolved CURRENT bank folder (the SAME M4 project-
|
|
// relative machinery the index/persist use — never a stale absolute path, so it is
|
|
// correct across a Save-As relocation), spells every enumerated entry with the index's
|
|
// own convention (bankRelativeForName — byte-identical to the capture path's spelling),
|
|
// and feeds the R1 pure core with (present, book().referencedPaths(), owned().paths()).
|
|
// Returns the orphan count + reclaimable bytes + the (possibly display-truncated) file
|
|
// list. The decision stays in the pure core — this method only enumerates, resolves,
|
|
// and stats. READ-ONLY across the whole persist seam: it writes NO ext-state, calls no
|
|
// save / MarkProjectDirty, and mutates neither the book, the manifest, nor any file.
|
|
//
|
|
// Yields an empty report (count 0) when there is no active/saved project or no bank
|
|
// folder on disk yet — an unsaved or never-captured project has nothing to reclaim.
|
|
PruneReport pruneDryRun() const;
|
|
|
|
// The FULL (untruncated) prune orphan set for the ACTIVE project — the same fresh
|
|
// enumerate + pure-core compute pruneDryRun() runs, but returning EVERY orphan (no
|
|
// 64-cap display clip) as project-relative index-spelled paths, in enumeration order.
|
|
// The R3 action calls this to obtain the exact set it will CONFIRM and then delete
|
|
// (pruneDryRun's truncated list is for the console readout; the delete set must be
|
|
// complete). READ-ONLY — no ext-state, no save, no file mutation. Empty when there is
|
|
// no active/saved project or no bank folder yet.
|
|
std::vector<std::string> pruneOrphanSet() const;
|
|
|
|
// Phase R (Reclaim), R3: DELETE the confirmed orphan set — the SOLE file-deletion path
|
|
// in ReaSampler, callable ONLY after an explicit user confirm of a specific manifest.
|
|
// Given the orphan set the user was shown and confirmed (`confirmed`, typically the
|
|
// full pruneOrphanSet() captured moments earlier), this re-enumerates the folder, runs
|
|
// the pure core FRESH, and deletes exactly `confirmed ∩ freshOrphans` (pruneDeletePlan)
|
|
// so a file that vanished or became referenced between confirm and delete is skipped,
|
|
// never wrongly deleted — and a newly-appeared orphan the user did NOT see is never
|
|
// swept. Deletion routes to the OS trash where a portable move-to-trash is verified
|
|
// (Windows Recycle Bin via SHFileOperation + FOF_ALLOWUNDO); elsewhere it falls back to
|
|
// std::filesystem unlink behind this confirm guardrail (see persist.cpp for per-platform
|
|
// routing). Non-throwing: every filesystem call uses error_code forms; a per-file
|
|
// failure (locked, already gone) is recorded and skipped, never thrown across the C ABI.
|
|
//
|
|
// Does NOT modify the BankIndex/book (orphans are unreferenced by definition) and does
|
|
// NOT modify the OwnedFileManifest (a reclaimed file drops out of the (owned ∩ present)
|
|
// algebra naturally once it is off disk — no persist write, so no undo-point question
|
|
// and no risk to the referenced/owned safety). Writes NO ext-state at all.
|
|
//
|
|
// No-ops (empty result) when there is no active/saved project, no bank folder, or the
|
|
// delete plan is empty (everything went stale). The caller is responsible for having
|
|
// shown the confirm; this method does NOT prompt.
|
|
PruneDeletionResult pruneReclaim(const std::vector<std::string>& confirmed) const;
|
|
|
|
// Poll the active project. Detects a project load (active project changed)
|
|
// and a Save-As (active project's .rpp path changed) and reacts accordingly.
|
|
// Intended to be driven by REAPER's "timer" register. Idempotent per tick.
|
|
//
|
|
// Also drains a pending undo/redo reload (requestReload): a Ctrl-Z / Ctrl-Shift-Z
|
|
// keeps the SAME project identity (same ReaProject*/GUID/.rpp path), so the
|
|
// identity classifier below reads it as NoOp and would never re-read ext state.
|
|
// The projectconfig hook (main.cpp) raises the reload flag on an undo/redo state
|
|
// restore; poll() honours it FIRST — reloading book_ + view_ + tail_ from the
|
|
// (now-restored) ext state of the current project — before the identity check, so
|
|
// the undo is reflected in-session without any content polling.
|
|
void poll();
|
|
|
|
// Request a reload of book_ + view_ + tail_ from the CURRENT active project's ext
|
|
// state on the next poll() tick. Raised by the projectconfig hook (main.cpp) ONLY
|
|
// on an undo/redo state restore (isUndo). Deferred (a flag, not an immediate read)
|
|
// because the projectconfig callback fires BEFORE REAPER has restored the project's
|
|
// <EXTSTATE> block — reading GetProjExtState synchronously there would return the
|
|
// PRE-undo value. Draining it on the next timer tick reads the restored value. This
|
|
// is REAPER-facing shell state; the request itself carries no REAPER types.
|
|
void requestReload();
|
|
|
|
// Load signal for the D4 reapply-on-open glue. poll() raises this whenever it
|
|
// (re)loads the view model from a project — prime, a project switch/open, or a
|
|
// forked-sibling load. consumeLoadSignal() returns true ONCE per load and clears
|
|
// it, so the integration layer (main.cpp) can react by reapplying the saved
|
|
// active mode's visibility exactly once, then goes quiet on idle ticks.
|
|
//
|
|
// Signal-based seam by design: persist stays MODEL-ONLY (it never calls the view
|
|
// shell), so there is no persist -> view dependency. main.cpp owns the glue —
|
|
// it drives both persist.poll() and view::applyMode, so the reapply wiring lives
|
|
// where those two already meet. D3 deliberately deferred exactly this to D4.
|
|
bool consumeLoadSignal();
|
|
|
|
private:
|
|
BankBook book_;
|
|
|
|
// The Design-View model. Default-constructed = Arrange + Design seeded, active
|
|
// = Arrange; loadFromProject leaves this default when a project has no stored
|
|
// view_state (older project), so an absent key is graceful, not a crash.
|
|
ViewModeModel view_;
|
|
|
|
// The tail setting. Default None / kDefaultManualTailMs; loadFromProject resets it
|
|
// to this default when a project has no stored tail_setting key (older / never-
|
|
// 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 writing-version stamp recovered on load (Phase V). Default PreVersioning;
|
|
// loadFromProject replaces it on every load path (peer to bank_/view_/tail_), so
|
|
// switching to a pre-versioning project reports PreVersioning rather than inheriting
|
|
// the previous project's stamp. Read-only to consumers via writingVersion().
|
|
WritingVersion writingVersion_;
|
|
|
|
// 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
|
|
// same-GUID case (different object = forked sibling -> Load; same object + new
|
|
// path -> Save-As) and drives forked-sibling re-divergence; the path tells a
|
|
// Save-As from an idle tick.
|
|
// Held as void* so the header stays REAPER-free; it is a compared-only opaque
|
|
// handle (never dereferenced), so a stale/recycled address is harmless.
|
|
void* lastProject_ = nullptr; // last active ReaProject* (opaque; compare only)
|
|
std::string lastGuid_; // "" until the first saved project is seen
|
|
std::string lastRppPath_; // .rpp path last seen for lastProject_
|
|
bool primed_ = false; // false until the first poll() observes state
|
|
bool loadPending_ = false; // raised by loadFromProject; drained by consumeLoadSignal
|
|
bool reloadRequested_ = false; // raised by requestReload (projectconfig undo/redo); drained by poll
|
|
|
|
// 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. 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);
|
|
};
|
|
|
|
} // namespace reasampler
|