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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -54,7 +54,10 @@ belong to the verbs.
|
||||
`PackageFileWriter` — after any extension append — and get its own consent if that
|
||||
re-checked path is `Present`; the dialog's confirm only ever covered the pre-append
|
||||
path. Not fixed at this seam: prompting is verb-level UX, and `pickPackageSavePath`
|
||||
has no caller yet, so the gap is latent, not live.
|
||||
has no caller yet, so the gap is latent, not live. **Closed on the export side:**
|
||||
`exportBank` re-checks `fileStatus()` on the post-append path and refuses
|
||||
`RefusedDestinationExists` until the caller sets `allowOverwrite`, which
|
||||
`package_export_action` does only after its own confirm naming that exact path.
|
||||
- **The rollback delete is prune's ONE carve-out, and only HALF of it is structural.**
|
||||
The citation and the full discriminator live at `package_rollback.cpp`'s header.
|
||||
"Did this call create it" is structural: only exclusively-created paths are
|
||||
@@ -77,6 +80,7 @@ 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.
|
||||
- `export_bank` — the promptless export verb, in three composable public steps: `surveyBankExport` (the read-only plan, report-before-acting), `digestSources` (measures each entry's length + `hashBytes` digest, one payload at a time), and `writePackageFile` (prefix, then each payload re-read and re-verified against that digest before it is appended, then commit). `exportBank` composes the three and gates on the plan verdict, the incomplete confirm and the destination confirm. The session arrives **const** — every mutator on it is non-const, so "an export writes no ext state, opens no undo point and never bumps the generation" is enforced by the type rather than remembered. Reads the session through inline accessors only, which is why its tests link and run without a DAW.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -9,6 +9,15 @@ 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)
|
||||
|
||||
# export_bank reads the live session through ReaSamplerSession's INLINE accessors only,
|
||||
# so it pulls in no REAPER-facing TU and its tests link (and run) without a DAW.
|
||||
reasampler_pure_library(export_bank
|
||||
SOURCES export_bank.cpp
|
||||
LINK PUBLIC export_plan bank_package package_io PRIVATE capture_paths wav_codec)
|
||||
reasampler_test(export_bank
|
||||
LINK export_bank bank_book slot_map view_mode_model tail_control origin_ledger
|
||||
tracking_authority prune_reconcile app_version capture_paths)
|
||||
|
||||
# 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,184 @@
|
||||
// export_bank.cpp — see export_bank.h for the contract.
|
||||
//
|
||||
// wav_codec is called for hashBytes ONLY. Payload bytes are copied and hashed, never
|
||||
// rebuilt, trimmed, normalized or collapsed — the capture path's mono collapse must
|
||||
// not reach an export.
|
||||
|
||||
#include "shell/package/export_bank.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile — the index's relative -> absolute
|
||||
#include "core/capture/wav_codec.h" // hashBytes
|
||||
#include "core/model/bank_book.h"
|
||||
#include "shell/package/package_io.h"
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — read through its inline book() only
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
package::SourceFileState stateOf(const std::string& absPath) {
|
||||
switch (fileStatus(absPath)) {
|
||||
case FileStatus::Present: return package::SourceFileState::Present;
|
||||
case FileStatus::Unreadable: return package::SourceFileState::Unreadable;
|
||||
case FileStatus::Absent: break;
|
||||
}
|
||||
return package::SourceFileState::Missing;
|
||||
}
|
||||
|
||||
std::vector<std::string> absoluteSources(const std::string& projectDir,
|
||||
const std::vector<std::string>& relativePaths) {
|
||||
std::vector<std::string> out;
|
||||
out.reserve(relativePaths.size());
|
||||
for (const std::string& rel : relativePaths)
|
||||
out.push_back(capture::resolveBankFile(projectDir, rel));
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
|
||||
const std::string& projectDir,
|
||||
const std::string& bankId) {
|
||||
ExportSurvey survey;
|
||||
const Bank* bank = session.book().bank(bankId);
|
||||
if (!bank) return survey;
|
||||
survey.bankFound = true;
|
||||
|
||||
package::ExportInputs inputs;
|
||||
inputs.bankDisplayName = bank->displayName;
|
||||
inputs.slots = bank->slots;
|
||||
for (const model::Sample& s : bank->index.all()) {
|
||||
package::ExportCandidate c;
|
||||
c.sample = s;
|
||||
c.fileState = stateOf(capture::resolveBankFile(projectDir, s.relativePath));
|
||||
inputs.candidates.push_back(std::move(c));
|
||||
}
|
||||
survey.plan = package::planExport(inputs);
|
||||
return survey;
|
||||
}
|
||||
|
||||
bool digestSources(package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
std::string& outFailedName) {
|
||||
outFailedName.clear();
|
||||
if (sourceAbsPaths.size() != manifest.entries.size()) return false;
|
||||
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
|
||||
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
|
||||
if (payload.empty()) {
|
||||
outFailedName = manifest.entries[i].fileName;
|
||||
return false;
|
||||
}
|
||||
manifest.entries[i].byteLength = payload.size();
|
||||
manifest.entries[i].byteHash = capture::hashBytes(payload.data(), payload.size());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
|
||||
const package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
const std::string& destAbsPath) {
|
||||
ExportOutcome out;
|
||||
if (sourceAbsPaths.size() != manifest.entries.size() ||
|
||||
encoded.layout.size() != manifest.entries.size()) {
|
||||
out.status = ExportStatus::EncodeFailed;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Every early return below abandons the writer through its destructor, which
|
||||
// removes the temp and leaves the destination untouched.
|
||||
PackageFileWriter writer(destAbsPath);
|
||||
if (!writer.ok() || !writer.appendRaw(encoded.prefix.data(), encoded.prefix.size())) {
|
||||
out.status = ExportStatus::WriteFailed;
|
||||
return out;
|
||||
}
|
||||
std::uint64_t written = encoded.prefix.size();
|
||||
|
||||
for (std::size_t i = 0; i < manifest.entries.size(); ++i) {
|
||||
const package::PackageEntry& entry = manifest.entries[i];
|
||||
const PayloadBuffer payload = readFilePayload(sourceAbsPaths[i]);
|
||||
if (payload.empty()) {
|
||||
out.status = ExportStatus::SourceReadFailed;
|
||||
out.offendingName = entry.fileName;
|
||||
return out;
|
||||
}
|
||||
if (payload.size() != entry.byteLength ||
|
||||
capture::hashBytes(payload.data(), payload.size()) != entry.byteHash) {
|
||||
out.status = ExportStatus::SourceChanged;
|
||||
out.offendingName = entry.fileName;
|
||||
return out;
|
||||
}
|
||||
if (!writer.appendPayload(payload)) {
|
||||
out.status = ExportStatus::WriteFailed;
|
||||
return out;
|
||||
}
|
||||
written += payload.size();
|
||||
}
|
||||
|
||||
if (written != encoded.totalSize || !writer.commit()) {
|
||||
out.status = ExportStatus::WriteFailed;
|
||||
return out;
|
||||
}
|
||||
out.status = ExportStatus::Written;
|
||||
out.entriesWritten = manifest.entries.size();
|
||||
out.bytesWritten = written;
|
||||
return out;
|
||||
}
|
||||
|
||||
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req) {
|
||||
ExportOutcome out;
|
||||
if (req.projectDir.empty()) {
|
||||
out.status = ExportStatus::NoProjectDir;
|
||||
return out;
|
||||
}
|
||||
|
||||
const ExportSurvey survey = surveyBankExport(session, req.projectDir, req.bankId);
|
||||
if (!survey.bankFound) {
|
||||
out.status = ExportStatus::NoSuchBank;
|
||||
return out;
|
||||
}
|
||||
out.bankDisplayName = survey.plan.manifest.bankDisplayName;
|
||||
out.excluded = survey.plan.excluded;
|
||||
|
||||
if (survey.plan.verdict == package::ExportVerdict::Refused) {
|
||||
out.status = ExportStatus::RefusedUnrepresentable;
|
||||
return out;
|
||||
}
|
||||
if (survey.plan.verdict == package::ExportVerdict::Incomplete && !req.allowIncomplete) {
|
||||
out.status = ExportStatus::RefusedIncomplete;
|
||||
return out;
|
||||
}
|
||||
// The save dialog's own overwrite confirm covered the path the USER chose, which
|
||||
// is not necessarily the path handed here (the picker re-appends `.rsbank`), so
|
||||
// consent for the real target is re-taken by the skin.
|
||||
if (!req.allowOverwrite && fileStatus(req.destAbsPath) == FileStatus::Present) {
|
||||
out.status = ExportStatus::RefusedDestinationExists;
|
||||
return out;
|
||||
}
|
||||
|
||||
package::PackageManifest manifest = survey.plan.manifest;
|
||||
manifest.exportTimestamp = req.exportTimestamp;
|
||||
const std::vector<std::string> sources =
|
||||
absoluteSources(req.projectDir, survey.plan.sourceRelativePaths);
|
||||
|
||||
if (!digestSources(manifest, sources, out.offendingName)) {
|
||||
out.status = ExportStatus::SourceReadFailed;
|
||||
return out;
|
||||
}
|
||||
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
|
||||
if (!encoded) {
|
||||
out.status = ExportStatus::EncodeFailed;
|
||||
return out;
|
||||
}
|
||||
|
||||
ExportOutcome written = writePackageFile(*encoded, manifest, sources, req.destAbsPath);
|
||||
written.bankDisplayName = out.bankDisplayName;
|
||||
written.excluded = std::move(out.excluded);
|
||||
return written;
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,91 @@
|
||||
// shell/package/export_bank — the promptless bank-export verb: survey, digest,
|
||||
// stream, commit. No prompts and no message boxes (shell/actions/
|
||||
// package_export_action is the skin). The session arrives CONST, which is how "an
|
||||
// export writes no ext state, opens no undo point and never bumps the bank
|
||||
// generation" is enforced rather than remembered — every mutator on the session is
|
||||
// non-const. Blocking I/O: UI-thread actions only.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/package/bank_package.h"
|
||||
#include "core/package/export_plan.h"
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
class ReaSamplerSession;
|
||||
|
||||
struct ExportRequest {
|
||||
std::string projectDir; // absolute; the root the index's relative paths hang off
|
||||
std::string bankId;
|
||||
std::string destAbsPath; // the .rsbank to write
|
||||
std::int64_t exportTimestamp = 0; // manifest envelope; the caller's clock read
|
||||
// Both default false and are set ONLY after the skin's explicit confirm: one
|
||||
// lists what is absent, the other names the destination being replaced.
|
||||
bool allowIncomplete = false;
|
||||
bool allowOverwrite = false;
|
||||
};
|
||||
|
||||
enum class ExportStatus {
|
||||
Written,
|
||||
NoSuchBank,
|
||||
NoProjectDir,
|
||||
RefusedIncomplete,
|
||||
RefusedUnrepresentable,
|
||||
RefusedDestinationExists,
|
||||
SourceReadFailed, // a file the plan classified Present would not read, or is empty
|
||||
SourceChanged, // a payload's bytes moved between the digest pass and the stream pass
|
||||
EncodeFailed,
|
||||
WriteFailed,
|
||||
};
|
||||
|
||||
struct ExportOutcome {
|
||||
ExportStatus status = ExportStatus::WriteFailed;
|
||||
std::size_t entriesWritten = 0;
|
||||
std::uint64_t bytesWritten = 0;
|
||||
std::string bankDisplayName;
|
||||
std::vector<package::ExcludedEntry> excluded;
|
||||
std::string offendingName; // the entry a SourceReadFailed / SourceChanged names
|
||||
};
|
||||
|
||||
struct ExportSurvey {
|
||||
bool bankFound = false;
|
||||
package::ExportPlan plan;
|
||||
};
|
||||
|
||||
// Report-before-acting: the same plan exportBank recomputes, with nothing written.
|
||||
// Read-only against both the project and the filesystem.
|
||||
ExportSurvey surveyBankExport(const ReaSamplerSession& session,
|
||||
const std::string& projectDir,
|
||||
const std::string& bankId);
|
||||
|
||||
// Fills each manifest entry's byteLength and byteHash from its source file — the
|
||||
// digest pass, one payload in memory at a time. False with `outFailedName` set when a
|
||||
// source will not read or is empty; a zero-length entry cannot round-trip the
|
||||
// format's own seam, so it is a failure here rather than an entry.
|
||||
bool digestSources(package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
std::string& outFailedName);
|
||||
|
||||
// Streams one package to `destAbsPath`: the encoded prefix, then each payload re-read
|
||||
// from `sourceAbsPaths` (parallel to `manifest.entries`) and re-checked against the
|
||||
// length and digest recorded for it before it is appended — so the digest the
|
||||
// manifest claims describes the bytes actually written, not the bytes a concurrent
|
||||
// edit replaced. Any failure abandons the writer, leaving the destination absent or
|
||||
// holding its prior contents.
|
||||
//
|
||||
// Public because that atomicity is this function's property: proving it needs a
|
||||
// failure injected mid-stream, which is a call to this seam, not to exportBank.
|
||||
ExportOutcome writePackageFile(const package::EncodedPackage& encoded,
|
||||
const package::PackageManifest& manifest,
|
||||
const std::vector<std::string>& sourceAbsPaths,
|
||||
const std::string& destAbsPath);
|
||||
|
||||
// The verb: plan, gate on the verdict and the destination, digest, encode, stream.
|
||||
ExportOutcome exportBank(const ReaSamplerSession& session, const ExportRequest& req);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "shell/panel/panel_state.h"
|
||||
#include "shell/panel/panel_bank_ops.h"
|
||||
|
||||
#include "shell/actions/package_export_action.h" // doBankPackageExport — the export skin
|
||||
#include "shell/bank_ops/bank_ops.h" // bankOp* promptless verbs
|
||||
#include "shell/persist/session.h" // ReaSamplerSession — the live session the ops mutate
|
||||
|
||||
@@ -241,6 +242,7 @@ enum : unsigned int {
|
||||
kMenuDelete,
|
||||
kMenuEvacuate,
|
||||
kMenuCreate,
|
||||
kMenuExport, // export this bank as a .rsbank package
|
||||
kMenuRemove, // remove selected sample(s) from the source bank
|
||||
kMenuMoveBase = 1000, // move-to-bank: kMenuMoveBase + destination index
|
||||
kMenuCopyBase = 2000, // copy-to-bank: kMenuCopyBase + destination index
|
||||
@@ -265,6 +267,7 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
menuAppend(menu, kMenuRename, "Rename...");
|
||||
menuAppend(menu, kMenuEvacuate, "Evacuate to pool", /*grayed=*/!nonEmpty);
|
||||
menuAppend(menu, kMenuDelete, "Delete...");
|
||||
menuAppend(menu, kMenuExport, "Export as package...");
|
||||
menuSeparator(menu);
|
||||
menuAppend(menu, kMenuCreate, "New bank...");
|
||||
|
||||
@@ -277,6 +280,7 @@ void showTabMenu(int screenX, int screenY, const std::string& bankId) {
|
||||
case kMenuRename: doRenameBank(bankId); break;
|
||||
case kMenuEvacuate: doEvacuateBank(bankId); break;
|
||||
case kMenuDelete: doDeleteBank(bankId); break;
|
||||
case kMenuExport: doBankPackageExport(*g_panel.session, bankId); break;
|
||||
case kMenuCreate: doCreateBank(); break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user