* feat(activity): scan new and unresolved files by default, all files on request
Scan for metadata now skips, before reading any metadata, the files the
Library already links with a resolved link (one generated_images row with
exactly one pair link), found with one streamed query that keeps only the
scanned paths and compares paths with os.path.normcase on both sides.
Skipped files count as Already known ("Skipped · already linked").
Unlinked, fallback-linked, ambiguous and unresolved files are read again.
POST /prompt_manager/jobs/scan takes {scope: "new" | "all"} (default
"new", anything else is a 400). The review dialog shows the concept's
"New and unresolved files" and adds an "All files" switch (D58). The
legacy /prompt_manager/scan route keeps scanning all files with unchanged
SSE payloads.
* feat(activity): download a job report
GET /prompt_manager/jobs/{id}/report returns the summary, counters, every
outcome group's listed rows with truncated flags, the scope and the log as a
JSON attachment (prompt-manager-<kind>-<YYYYMMDD-HHMM>.json), with no absolute
paths. The job view's Download report saves it through the api-client URL, and
the scan log opens at its newest line and follows new lines only while the
reader is at the bottom.
* fix(activity): re-read known files whose size changed, and review follow-ups
The new scope now skips a known, resolved file only while its stored
generated_images.file_size equals its size on disk; a NULL stored size is
re-read. A file replaced at the same path is read again and its conflict
shows under Needs review. The scan stores each file's size when it ingests
it, and both sides of the known-set match go through normalize_image_path.
A failed known-set read logs and falls back to reading every file, and an
empty request body asks for the default scope. The All files switch is
described by its hint, a lost job's Download report is disabled, the report
toast is skipped once the view is gone, and the scan log opens at its
newest line every time. Fixtures use the real scope shape.
* test(activity): wait for the job view's settled detail read, not a fixed delay
The 200-row cap test now starts counting detail reads once one read has
returned the full listing and none is in flight. The job view re-reads after
an in-flight read only when the summary still lists more than that read
returned, so no redundant read is left due after opening.
106 lines
4.7 KiB
JavaScript
106 lines
4.7 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.';
|
|
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.';
|
|
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`. */
|
|
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. */
|
|
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')));
|
|
notify(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.
|
|
*/
|
|
export async function startMetadataScan({ store, scope, files = 'new', notify = showWorkspaceToast, navigate = navigateTo }) {
|
|
try {
|
|
const result = await store.start('scan', { scope: files });
|
|
if (scope.signal.aborted) return;
|
|
if (result.busy !== undefined) {
|
|
notify(busyToast(result.busy));
|
|
return;
|
|
}
|
|
navigate(`#/activity/${result.job.id}`);
|
|
} catch {
|
|
if (!scope.signal.aborted) notify(START_FAILED);
|
|
}
|
|
}
|
|
|
|
/** Open "Review metadata scan" for `sources` output folders. */
|
|
export function openMetadataScan({ scope, store, sources, trigger = null }) {
|
|
openScanReviewDialog({ scope, sources, trigger,
|
|
onBackup: () => downloadDatabaseBackup(),
|
|
onStart: ({ scope: files }) => startMetadataScan({ store, scope, files }) });
|
|
}
|