* feat(maintenance): repair old Pair prompts' empty category and tags (#550) Squashed from PR #579. Closes #550. * feat(tagging): saved instructions with a default per engine (#431) Squashed from PR #587. * feat(tagging): pick saved instructions in the tagging dialogs (#431) Squashed from PR #588. Closes #431.
175 lines
9.9 KiB
JavaScript
175 lines
9.9 KiB
JavaScript
/**
|
||
* System › Maintenance (#249 System S7 Task 19): the concept's `renderSystem` "maintenance" body (app.js 688–690).
|
||
*
|
||
* - The notice "Check first. Review changes. Then apply." with "Run health checks" (D123), then the surfaces
|
||
* "Database" and "Images & cache" with the concept's eight operations, in its order, labels and danger marks, plus
|
||
* D192 (#550, beyond the concept): "Pair category and tags" after "Unused prompt components".
|
||
* - Integrity & relationship checks → Check runs the same read-only health check and opens the Health report (D95);
|
||
* its "Review missing links" reviews a fresh Missing-file links plan, never a cleanup.
|
||
* - Duplicate prompt text, Optimize database, Unused prompt components, Pair category and tags (`pair_metadata`: fill
|
||
* old Pair prompts' empty category and tags from their saved pairs) and Missing-file links → Review plan open the
|
||
* D100 plan review (maintenance-review.mjs), which applies only what it showed, as an Activity job.
|
||
* - Duplicate image files → Find duplicates starts the duplicate search as an Activity job and opens it; its view
|
||
* offers Review groups (D101) once it found some.
|
||
* - Thumbnail cache → Review options opens the S5 "Thumbnail maintenance" dialog (D99).
|
||
* - D102 (Q5): Metadata cache clears only the browser entries Prompt Manager owns as a cache (cache-ownership.js,
|
||
* #222). The inventory has none, so the row says "Nothing is cached in this browser." and its Review plan is
|
||
* aria-disabled; with owned entries it reviews and clears exactly those.
|
||
*/
|
||
import '../cache-ownership.js';
|
||
import { navigateTo } from './state.mjs';
|
||
import { showWorkspaceToast } from './toast.mjs';
|
||
import { operationRow } from './system-controls.mjs';
|
||
import { healthNotice, runHealthAction } from './system-overview.mjs';
|
||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||
import { busyToast } from './activity-scan.mjs';
|
||
import { openReviewDialog } from './workspace-dialogs.mjs';
|
||
import { reviewPlan } from './maintenance-review.mjs';
|
||
import { noun } from './plural.mjs';
|
||
|
||
export const DUPLICATES_KIND = 'duplicates';
|
||
export const FIND_FAILED = 'The duplicate search could not start. Try again.';
|
||
export const NOTHING_CACHED_REASON = 'Nothing is cached in this browser.';
|
||
export const STORAGE_UNCHECKED_REASON = 'This browser did not allow checking its storage.';
|
||
export const METADATA_CACHE_DESCRIPTION = 'Clear only Prompt Manager metadata cached by this browser.';
|
||
|
||
/** The concept's two surfaces and their rows: `[title, description, label, action, danger]`. */
|
||
export const MAINTENANCE_SURFACES = Object.freeze([
|
||
['Database', [
|
||
['Integrity & relationship checks', 'Validate foreign keys, text hashes, pair uniqueness and image associations.', 'Check', 'health'],
|
||
['Duplicate prompt text', 'Review canonical text reuse and dependency-safe merging.', 'Review plan', 'duplicate_prompts'],
|
||
['Optimize database', 'Reclaim unused SQLite space. This does not delete prompts or media.', 'Review plan', 'optimize'],
|
||
['Unused prompt components', 'Preview unreferenced components. Pair references and history remain protected.', 'Review plan',
|
||
'unused_components', true],
|
||
// D192 (#550): beyond the concept. Fills only empty fields, deletes nothing (no danger mark).
|
||
['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs saved, from their saved ' +
|
||
'pairs. Only empty fields of never-edited prompts.', 'Review plan', 'pair_metadata'],
|
||
]],
|
||
['Images & cache', [
|
||
['Missing-file links', 'Remove stale database links only. Files on disk are never deleted by this operation.', 'Review plan',
|
||
'missing_links', true],
|
||
['Duplicate image files', 'Find matching file content, review groups and choose what to keep. Separate from duplicate prompt text.',
|
||
'Find duplicates', 'duplicates'],
|
||
['Thumbnail cache', 'Generate missing thumbnails or explicitly rebuild all.', 'Review options', 'thumbnails'],
|
||
['Metadata cache', METADATA_CACHE_DESCRIPTION, 'Review plan', 'metadata_cache'],
|
||
]],
|
||
]);
|
||
|
||
function el(tag, text, className) {
|
||
const node = document.createElement(tag);
|
||
if (text !== undefined) node.textContent = text;
|
||
if (className) node.className = className;
|
||
return node;
|
||
}
|
||
|
||
const ownership = () => window.CacheOwnership;
|
||
const browserStorage = () => window.localStorage;
|
||
const cacheStorage = () => ('caches' in window ? window.caches : null);
|
||
|
||
/** Start the duplicate search and open its job; a refused or failed start says why and stays. */
|
||
export async function findDuplicates({ store, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||
try {
|
||
const result = await store.start(DUPLICATES_KIND);
|
||
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(FIND_FAILED);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* D102: the Metadata cache row's description: the concept's, plus the reason its review is unavailable. A browser that
|
||
* refused to list its storage is never claimed empty.
|
||
*/
|
||
export function metadataCacheDescription(entries) {
|
||
if (entries.keys.length + entries.caches.length > 0) return METADATA_CACHE_DESCRIPTION;
|
||
return `${METADATA_CACHE_DESCRIPTION} ${entries.denied ? STORAGE_UNCHECKED_REASON : NOTHING_CACHED_REASON}`;
|
||
}
|
||
|
||
/** D102: review the owned browser entries, then clear exactly those and say what happened. */
|
||
export function openMetadataCacheReview({ scope, entries, trigger = null, notify = showWorkspaceToast,
|
||
cache = ownership(), onCleared = () => {} }) {
|
||
const count = entries.keys.length + entries.caches.length;
|
||
openReviewDialog({ scope, trigger, title: 'Review maintenance plan', heading: 'Metadata cache',
|
||
grid: [['Scope', `${count.toLocaleString('en-US')} cached ${noun(count, 'entry', 'entries')} in this browser`],
|
||
['Protected', 'Preferences and other tools’ data in this browser'],
|
||
['Backup', 'Not needed: the database is not changed'],
|
||
['Media on disk', 'No files are removed']],
|
||
sentence: [...entries.keys, ...entries.caches].join(' · '),
|
||
cancelLabel: 'Cancel', primary: { label: 'Clear browser cache' },
|
||
onStart: async () => {
|
||
const result = await cache.clearOwned({ storage: browserStorage, cacheStorage: cacheStorage() });
|
||
if (scope.signal.aborted) return;
|
||
notify(cache.clearMessage(result));
|
||
onCleared();
|
||
} });
|
||
}
|
||
|
||
/** The Metadata cache row: aria-disabled with its reason while nothing is owned (D102). */
|
||
function metadataCacheRow({ scope, notify, cache }) {
|
||
const row = operationRow({ scope, title: 'Metadata cache', description: METADATA_CACHE_DESCRIPTION, label: 'Review plan',
|
||
onClick: async button => {
|
||
if (button.getAttribute('aria-disabled') === 'true') return;
|
||
const entries = await cache.ownedEntries({ storage: browserStorage, cacheStorage: cacheStorage() });
|
||
if (scope.signal.aborted) return;
|
||
if (entries.keys.length + entries.caches.length === 0) {
|
||
paint(entries);
|
||
return;
|
||
}
|
||
openMetadataCacheReview({ scope, entries, trigger: button, notify, cache, onCleared: refresh });
|
||
} });
|
||
const button = row.querySelector('.row-actions > button');
|
||
const description = row.querySelector('p');
|
||
function paint(entries) {
|
||
description.textContent = metadataCacheDescription(entries);
|
||
if (entries.keys.length + entries.caches.length > 0) button.removeAttribute('aria-disabled');
|
||
else button.setAttribute('aria-disabled', 'true');
|
||
}
|
||
function refresh() {
|
||
cache.ownedEntries({ storage: browserStorage, cacheStorage: cacheStorage() }).then(entries => {
|
||
if (!scope.signal.aborted) paint(entries);
|
||
});
|
||
}
|
||
paint({ keys: [], caches: [] });
|
||
refresh();
|
||
return row;
|
||
}
|
||
|
||
/**
|
||
* The Health report's "Review missing links" (D95): a fresh Missing-file links plan, never a cleanup, holding the pressed
|
||
* button while it is read. Maintenance and Diagnostics & logs (S12) pass it to `runHealthAction`.
|
||
*/
|
||
export function reviewMissingLinks({ api, store, scope, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||
return (trigger, pressed = trigger) => reviewPlan({ api, store, scope, operation: 'missing_links', trigger, hold: pressed,
|
||
notify, navigate });
|
||
}
|
||
|
||
/**
|
||
* The Maintenance section's mount (`mountSystem`'s `sections.maintenance`): the notice and the two surfaces. `activity`
|
||
* is the page's Activity store.
|
||
*/
|
||
export function mountMaintenance(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast,
|
||
cache = ownership() }) {
|
||
const reviewMissing = reviewMissingLinks({ api, store: activity, scope, notify, navigate });
|
||
const actions = {
|
||
health: button => runHealthAction({ api, activity, scope, button, onReviewMissing: reviewMissing, notify }),
|
||
duplicates: () => findDuplicates({ store: activity, scope, notify, navigate }),
|
||
thumbnails: button => openThumbnailReview({ api, store: activity, scope, trigger: button, navigate }),
|
||
};
|
||
const surfaces = MAINTENANCE_SURFACES.map(([heading, rows]) => {
|
||
const surface = el('section', undefined, 'surface');
|
||
surface.append(el('h2', heading), ...rows.map(([title, description, label, action, danger = false]) => {
|
||
if (action === 'metadata_cache') return metadataCacheRow({ scope, notify, cache });
|
||
const onClick = actions[action]
|
||
?? (button => reviewPlan({ api, store: activity, scope, operation: action, trigger: button, notify, navigate }));
|
||
return operationRow({ scope, title, description, label, danger, onClick });
|
||
}));
|
||
return surface;
|
||
});
|
||
return [healthNotice({ api, activity, scope, navigate, notify, onReviewMissing: reviewMissing }), ...surfaces];
|
||
}
|