package: one bank leaves the project as one .rsbank, or the export refuses and says why

Pure planner classifies missing/unreadable/unrepresentable and repairs transport
names; the verb digests, streams and commits atomically over a const session.
This commit is contained in:
2026-08-02 13:21:43 -04:00
parent 33ea95078d
commit 081b6f1028
16 changed files with 1426 additions and 2 deletions
+1
View File
@@ -39,6 +39,7 @@ is owned by other directories and only skinned here.
## Modules
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `package_export_action` — the "export bank as package" skin: survey and report first, confirm what is absent (and, separately, a destination being replaced), pick a destination, write. Every prompt in the flow lives here so `shell/package/export_bank` stays promptless. Read-only against the project — it holds the session by `const&`, so no ext-state write, generation bump or undo point is reachable. Registration rides `main.cpp`'s action table (`EXPORT_BANK_PACKAGE`); the panel's tab menu is the second skin over the same body.
- `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.
+190
View File
@@ -0,0 +1,190 @@
// package_export_action.cpp — see package_export_action.h for the contract this TU
// preserves. main.cpp owns the API pointers; this TU gets them extern.
#include "shell/actions/package_export_action.h"
#include <cstddef>
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
#include "core/capture/capture_paths.h" // projectDirOfRpp, sanitizeStem
#include "shell/package/export_bank.h"
#include "shell/package/package_pickers.h"
#include "shell/persist/session.h"
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_EnumProjects
#define REAPERAPI_WANT_ShowConsoleMsg
#define REAPERAPI_WANT_ShowMessageBox
#include "reaper_plugin_functions.h"
namespace reasampler {
namespace {
constexpr const char* kUnsavedProjectMsg =
"ReaSampler export: save the project first -- an unsaved project has no bank folder "
"to read from.\n";
std::string currentProjectDir() {
std::vector<char> buf(4096, '\0');
EnumProjects(-1, buf.data(), static_cast<int>(buf.size()));
return capture::projectDirOfRpp(std::string(buf.data()));
}
// 6 == YES; anything else cancels (SDK ~6544).
bool confirmed(const std::string& msg, const char* title) {
return ShowMessageBox(msg.c_str(), title, 4) == 6;
}
std::string entryLine(const package::ExcludedEntry& e) {
const char* why = e.reason == package::ExclusionReason::FileMissing ? "missing"
: e.reason == package::ExclusionReason::FileUnreadable ? "unreadable"
: "unusable index record";
return " " + (e.displayName.empty() ? e.sampleId : e.displayName) + " [" + why +
"] " + e.relativePath + "\n";
}
// `maxLines` == 0 lists everything (the console record); a positive cap keeps a
// confirm dialog readable on a bank with hundreds of absent files, prune's own
// truncate-the-confirm-not-the-report discipline.
std::string excludedManifest(const std::vector<package::ExcludedEntry>& excluded,
std::size_t maxLines) {
std::string msg;
std::size_t shown = 0;
for (const package::ExcludedEntry& e : excluded) {
if (maxLines != 0 && shown == maxLines) {
msg += " ... (" + std::to_string(excluded.size() - shown) +
" more, listed in the console)\n";
break;
}
msg += entryLine(e);
++shown;
}
return msg;
}
void reportOutcome(const ExportOutcome& out, const std::string& destPath) {
switch (out.status) {
case ExportStatus::Written:
ShowConsoleMsg(("ReaSampler export: wrote " + std::to_string(out.entriesWritten) +
" entry/entries (" + std::to_string(out.bytesWritten) +
" bytes) to " + destPath + "\n")
.c_str());
return;
case ExportStatus::SourceReadFailed:
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + out.offendingName +
"\" could not be read. Nothing was written.\n")
.c_str());
return;
case ExportStatus::SourceChanged:
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + out.offendingName +
"\" changed on disk while the package was being written. "
"Nothing was written; run the export again.\n")
.c_str());
return;
case ExportStatus::EncodeFailed:
ShowConsoleMsg("ReaSampler export: ABORTED -- this bank could not be encoded "
"as a package. Nothing was written.\n");
return;
// Both refusals are re-derived from a FRESH plan, so reaching them after the
// survey means the bank changed under the export, not that the user declined.
case ExportStatus::RefusedIncomplete:
case ExportStatus::RefusedUnrepresentable:
ShowConsoleMsg("ReaSampler export: ABORTED -- the bank changed between the "
"report and the write. Nothing was written; run the export "
"again.\n");
return;
case ExportStatus::RefusedDestinationExists:
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
return;
case ExportStatus::NoSuchBank:
ShowConsoleMsg("ReaSampler export: that bank no longer exists.\n");
return;
case ExportStatus::NoProjectDir:
ShowConsoleMsg(kUnsavedProjectMsg);
return;
case ExportStatus::WriteFailed:
ShowConsoleMsg(("ReaSampler export: FAILED writing " + destPath +
". No package was left behind; any file already at that path is "
"untouched.\n")
.c_str());
return;
}
}
} // namespace
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId) {
const std::string projectDir = currentProjectDir();
if (projectDir.empty()) {
ShowConsoleMsg(kUnsavedProjectMsg);
return;
}
// Report before acting, and before the picker opens: a refusal the user cannot
// act on should not cost them a trip through a save dialog first.
const ExportSurvey survey = surveyBankExport(session, projectDir, bankId);
if (!survey.bankFound) {
ShowConsoleMsg("ReaSampler export: no such bank.\n");
return;
}
const std::string bankName = survey.plan.manifest.bankDisplayName;
bool allowIncomplete = false;
if (survey.plan.verdict == package::ExportVerdict::Refused) {
ShowConsoleMsg(("ReaSampler export: ABORTED -- \"" + bankName +
"\" holds index record(s) a package cannot carry. Nothing was "
"written.\n" +
excludedManifest(survey.plan.excluded, 0))
.c_str());
return;
}
if (survey.plan.verdict == package::ExportVerdict::Incomplete) {
const std::string headline =
"ReaSampler export: \"" + bankName + "\" has " +
std::to_string(survey.plan.excluded.size()) +
" entry/entries whose file is missing or unreadable:\n";
ShowConsoleMsg((headline + excludedManifest(survey.plan.excluded, 0)).c_str());
if (!confirmed(headline + excludedManifest(survey.plan.excluded, 10) +
"\nExport the " +
std::to_string(survey.plan.manifest.entries.size()) +
" present entry/entries anyway?",
"ReaSampler: incomplete bank")) {
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
return;
}
allowIncomplete = true;
}
// The bank's own name, not the project's: the artifact is a bank, and a user
// exporting three banks from one project needs three distinguishable files.
const std::string suggested =
projectDir + "/" + capture::sanitizeStem(bankName) + ".rsbank";
std::string dest;
if (!pickPackageSavePath(suggested, dest)) return; // user cancelled the picker
ExportRequest req;
req.projectDir = projectDir;
req.bankId = bankId;
req.destAbsPath = dest;
req.exportTimestamp = static_cast<std::int64_t>(std::time(nullptr));
req.allowIncomplete = allowIncomplete;
ExportOutcome out = exportBank(session, req);
if (out.status == ExportStatus::RefusedDestinationExists) {
if (!confirmed("A file already exists at:\n\n " + dest +
"\n\nReplace it with this bank package?",
"ReaSampler: replace package")) {
ShowConsoleMsg("ReaSampler export: cancelled -- nothing was written.\n");
return;
}
req.allowOverwrite = true;
out = exportBank(session, req);
}
reportOutcome(out, dest);
}
} // namespace reasampler
+18
View File
@@ -0,0 +1,18 @@
#pragma once
// package_export_action — the "export bank as package" action body: survey and
// report first, confirm what is absent, pick a destination, write. Every prompt in
// the flow lives here; shell/package/export_bank stays promptless. Registration and
// dispatch for its FOREVER-STABLE id ride main.cpp's action table.
#include <string>
namespace reasampler {
class ReaSamplerSession;
// Exports one bank (the pool included — it is structurally a bank) to a .rsbank the
// user picks. Read-only against the project: the session is const, so no ext-state
// write, generation bump or undo point is reachable from here.
void doBankPackageExport(const ReaSamplerSession& session, const std::string& bankId);
} // namespace reasampler