* feat(system): back up, restore and export natively System > Backup & export replaces its hand-off with the concept's section on the S8 backend: Back up downloads the verified copy and the status line shows only what GET /backup/status reports (D103); Review restore stages a chosen file and shows the server's review in the shared review dialog, Cancel discards the stage and Restore runs the Database restore job, after which the open views reload (D105); Export JSON plus the components and saved-pairs exports (D103); Import / inspect opens the shared Import & inspect dialog; the safety-backup policy saves through the Save row and scheduled backups offer Manual only (D104). Overview's Backup status row reads the same status. Refs #326 * test(system): compare Backup & export with the concept system-backups-concept.spec compares the section, the Restore preview and the Import & inspect dialog with the concept in both themes at 1600 and 390 wide, mapping only D103, D104, D105 (and D15/D55 for the shared Import dialog), and proves a renamed label, a removed row, a lost danger mark and swapped tokens are noticed. A review's grid values now wrap anywhere (D105), so a long backup file name keeps the concept's two equal columns at phone width. Refs #326 * docs(testing): Backup & export contract (D103-D105) Refs #326 * test(coverage): raise the frontend floors S9 measured system-backups.mjs gets its measured floor in place of the provisional one; admin.js, activity-scan.mjs and maintenance-review.mjs branches go up. Refs #326 * fix(system): keep a refused or handed-back restore review usable A Restore refused as busy now opens the same review again with Discard / Restore, and a restore job whose leases did not drain hands its review back (scope.review_kept) to the page that started it, which reopens it; every other path that abandons a token after Restore sends its DELETE, so held reviews can no longer lock restore out. The kept-review line says where the review is, and a full review store says what to do (I1). applyPlan resolves with its outcome and its job tracking now has failing-before proof (N5). Also: the safety-backup toggle says what the policy does (D104, M2); the restore picker has no type filter so safety backups can be chosen (M3); "Export all prompts JSON" names what /export holds (N1); small snapshots show KB (N3); the duplicated holding and save helpers are shared (N4); a dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1). Refs #326 * test(coverage): raise the system-backups branch floor Refs #326
128 lines
6.2 KiB
JavaScript
128 lines
6.2 KiB
JavaScript
/**
|
||
* Scan for metadata (#249 Activity-1, D55): Images → Import → "Scan for metadata" → Review scan opens the concept's
|
||
* "Review metadata scan". Its backup box downloads the existing `/prompt_manager/backup` copy and toasts the outcome;
|
||
* Start scan starts the session job through the Activity store over the chosen scope (new and unresolved files, or
|
||
* all files with the D58 checkbox), which toasts "Scan started.", and opens the job's view.
|
||
* A start refused because another record-changing job runs toasts "Another job is running: <title>." with a link to
|
||
* that job (D57).
|
||
*/
|
||
import { openScanReviewDialog } from './workspace-dialogs.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { navigateTo } from './state.mjs';
|
||
|
||
export const BACKUP_URL = '/prompt_manager/backup';
|
||
export const BACKUP_DONE = 'Database backup downloaded.';
|
||
// S8 M5: a copy that failed a semantic check is still sent, with the reason in this header ('ok' otherwise).
|
||
export const VERIFY_HEADER = 'X-PromptManager-Verify';
|
||
/** The toast for a downloaded copy the server could not verify (S9). */
|
||
export const backupUnverified = reason => `Database backup downloaded, but it did not pass verification: ${reason}`;
|
||
export const BACKUP_FAILED = 'The database could not be backed up. Try again.';
|
||
export const REPORT_DONE = 'Job report downloaded.';
|
||
export const REPORT_FAILED = 'The report could not be downloaded. Try again.';
|
||
const JOBS_REPORT_BASE = '/prompt_manager/jobs';
|
||
export const START_FAILED = 'The scan could not start. Try again.';
|
||
// A per-source scan whose sources changed since they were read (S4): the server's 409 says so.
|
||
const SOURCES_CHANGED_CODE = 'sources-changed';
|
||
const FALLBACK_NAME = 'prompts_backup.db';
|
||
const REPORT_FALLBACK_NAME = 'prompt-manager-report.json';
|
||
|
||
/** The download name from a Content-Disposition header, or `fallback`. */
|
||
export function backupFilename(disposition, fallback = FALLBACK_NAME) {
|
||
const match = /filename="?([^";]+)"?/i.exec(disposition || '');
|
||
return match ? match[1] : fallback;
|
||
}
|
||
|
||
/** Save a fetched response through a temporary link, named by `filename` (also System's exports, S9). */
|
||
export function saveResponse(blob, filename) {
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = filename;
|
||
document.body.append(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
}
|
||
|
||
/**
|
||
* Download the prompts database copy the legacy admin page also uses; toasts the outcome, and says so when the server
|
||
* sent a copy it could not verify (S9).
|
||
*/
|
||
export async function downloadDatabaseBackup({ fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast } = {}) {
|
||
try {
|
||
const response = await fetchUrl(BACKUP_URL);
|
||
if (!response.ok) throw new Error(`Backup failed with ${response.status}`);
|
||
const blob = await response.blob();
|
||
saveResponse(blob, backupFilename(response.headers.get('Content-Disposition')));
|
||
const verify = response.headers.get(VERIFY_HEADER);
|
||
notify(verify && verify !== 'ok' ? backupUnverified(verify) : BACKUP_DONE);
|
||
return true;
|
||
} catch {
|
||
notify(BACKUP_FAILED);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Download a job's JSON report (Activity-2, D59) through the api-client URL, so a proxy mount works; toasts the outcome
|
||
* unless `signal` (the view's scope) was aborted meanwhile.
|
||
*/
|
||
export async function downloadJobReport(jobId, { fetchUrl = window.ApiClient.fetchUrl, notify = showWorkspaceToast,
|
||
signal = null } = {}) {
|
||
try {
|
||
const response = await fetchUrl(`${JOBS_REPORT_BASE}/${encodeURIComponent(jobId)}/report`);
|
||
if (!response.ok) throw new Error(`Report failed with ${response.status}`);
|
||
const blob = await response.blob();
|
||
saveResponse(blob, backupFilename(response.headers.get('Content-Disposition'), REPORT_FALLBACK_NAME));
|
||
// The file is saved either way; a view left meanwhile (`signal` aborted) shows no toast.
|
||
if (!signal?.aborted) notify(REPORT_DONE);
|
||
return true;
|
||
} catch {
|
||
if (!signal?.aborted) notify(REPORT_FAILED);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** The toast for a refused start, linking the job that holds the slot. */
|
||
export function busyToast(running) {
|
||
if (!running) return 'Another job is running. Try again when it ends.';
|
||
return ['Another job is running: ', { text: running.title, href: `#/activity/${running.id}` }, '.'];
|
||
}
|
||
|
||
/**
|
||
* Start the scan job over `files` ('new': new and unresolved files, or 'all') and open its view; a refused or failed
|
||
* start says why and stays on the page. `roots` (System › Sources, S4) limits it to those effective source indexes, and
|
||
* `paths` names the public path read for each: when the sources changed since (409 `sources-changed`) nothing starts,
|
||
* the toast says the server's reason and `onStale` (which every caller with `roots` passes) reads them again.
|
||
*/
|
||
export async function startMetadataScan({ store, scope, files = 'new', roots = null, paths = null, onStale,
|
||
notify = showWorkspaceToast, navigate = navigateTo }) {
|
||
try {
|
||
const result = await store.start('scan', roots ? { scope: files, roots, paths } : { scope: files });
|
||
if (scope.signal.aborted) return;
|
||
if (result.busy !== undefined) {
|
||
notify(busyToast(result.busy));
|
||
return;
|
||
}
|
||
navigate(`#/activity/${result.job.id}`);
|
||
} catch (error) {
|
||
if (scope.signal.aborted) return;
|
||
if (error?.code === SOURCES_CHANGED_CODE) {
|
||
notify(error.message);
|
||
onStale();
|
||
return;
|
||
}
|
||
notify(START_FAILED);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Open "Review metadata scan" for `sources` output folders; with `roots` and `paths` (one source's index and public
|
||
* path, System › Sources) the scan reads only those, and `onStale` runs when they changed since they were read.
|
||
*/
|
||
export function openMetadataScan({ scope, store, sources, roots = null, paths = null, onStale, trigger = null }) {
|
||
openScanReviewDialog({ scope, sources, trigger,
|
||
onBackup: () => downloadDatabaseBackup(),
|
||
onStart: ({ scope: files }) => startMetadataScan({ store, scope, files, roots, paths, onStale }) });
|
||
}
|