* feat(trash): a freedesktop trash backend that renames without replacing or refuses (#480) * feat(images): remove a file's records and hide it with a removal mark (#480) Deletes every record of one file under every recorded spelling (image links, tags, auto-tag marks, pending review items, category, collection memberships, favorite) in one transaction. A Needs review item that is kept keeps its annotation rows (ON DELETE RESTRICT). The image_removals table hides an exact file (locator, size, mtime_ns) until it changes; linking skips a marked file, and an explicit tag, category, favorite or collection write shows it again. * feat(images): review and apply Delete images as a reviewed plan job (#480) POST /prompt_manager/images/delete/preview reviews both modes; the maintenance apply takes {token, mode}. Library mode removes the records and writes the removal mark; trash mode moves the file first and removes its records only after a successful move. The workspace listing, the output scan, the duplicate find and Tag output images skip marked files. * feat(images): Delete… in the Images selection bar, inspector and viewer (#480) The concept's Remove links… place, D159: one review per selection that asks each time (nothing preselected) whether to remove the files from the library only or also move them to the trash. The browser test server uses a fake trash. * test(images): portable delete tests, the native no-trash proof, D159 in TESTING.md (#480) * perf(images): cache the removal marks so a listing page reads no extra table (#480) The marks reload only when this process writes or clears one, or a restore replaces the database. The route snapshot gains the preview route. * test(images): the full inspector specs expect D159's Delete… in the panel and the viewer (#480) * fix(images): the delete review starts nothing without a chosen mode, and dims its primary until then (#480) An aria-disabled primary ignores every activation, a chosen one starts once, and the apply helper refuses a missing mode. Tests cover a forced click, a double click, Enter and Space on the primary, Enter in the dialog and a submit event, and the server refusing an apply without an exact mode.
391 lines
21 KiB
JavaScript
391 lines
21 KiB
JavaScript
import { createButton } from '../shared/toolkit/core.js';
|
|
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
|
|
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
|
|
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
|
|
import { CATEGORY_DIALOG, CATEGORY_MAX_LENGTH, CATEGORY_URL, MAX_CATEGORY_IMAGES, REMOVE_DIALOG, USE_PROMPT_CATEGORY,
|
|
NONE_FOUND, clearOutcome, failureMessage, removeQuestion, setOutcome } from './images-category.mjs';
|
|
import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs';
|
|
import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
|
|
import { showWorkspaceToast } from './toast.mjs';
|
|
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
|
|
import { openAddToCollection, removeFromCollection } from './images-collections.mjs';
|
|
import { openDeleteImages } from './images-delete.mjs';
|
|
|
|
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
|
const CHECKBOX_LABEL = '.image-overlays > label';
|
|
const CARD = 'article[data-image-key]';
|
|
// D135: Export metadata reads every image's inspection in the browser, so it waits for loaded images.
|
|
export const EXPORT_LOADED_ONLY = 'Export metadata reads each image in the browser, so it works on loaded images only.';
|
|
|
|
function el(tag, text, className) {
|
|
const element = document.createElement(tag);
|
|
if (text !== undefined) element.textContent = text;
|
|
if (className) element.className = className;
|
|
return element;
|
|
}
|
|
|
|
/**
|
|
* The Images page's checked cards and the concept's selection bar (#249 D8; app.js renderImages, updateBulk and the
|
|
* bulk handlers). `element` is the `#bulkbar` the page places between its filter row and gallery layout: empty while
|
|
* nothing is checked, else "N selected" and the real actions in the concept's order. The page renders each card's
|
|
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
|
|
*
|
|
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
|
|
* - `onCategorySet()` reloads the categories and the listing after a successful Set category or Use prompt category
|
|
* (the reload clears the selection through `clear`); focus then moves to `focusFallback` if it was on the hidden
|
|
* action. `getCategories()` gives the toolbar's categories, the field's suggestions.
|
|
* - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
|
|
* - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags
|
|
* leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays.
|
|
*
|
|
* File-manager selection (#392, D135, beyond the concept's checkbox): the anchor is the last image toggled on its own;
|
|
* Shift+click (a checkbox or anywhere on a card) applies the anchor's range, Ctrl/Cmd+click toggles a card without
|
|
* opening it, a touch long-press starts touch selection mode in which taps toggle. `selectAll`, `extend` and
|
|
* `clear` serve the grid's keys (images.mjs). `getListing()` gives `{total, hasMore, positiveCount}` and
|
|
* `getFilter()` the listing filter: once every loaded image is checked and more match, the bar offers "Select all N
|
|
* matching", whose actions send that filter to the server.
|
|
*
|
|
* Collections (#331 D138, design §7): with the client store `collections`, Add to collection follows Set category;
|
|
* while a collection filter is active (`collection`, its id) Remove from collection follows it, and a confirmed remove
|
|
* calls `onCollectionsChanged()` to reload the listing. A stale source on either calls `onSourceChanged()`.
|
|
*/
|
|
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
|
|
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()), collections = null,
|
|
collection = null, onCollectionsChanged = null, onSourceChanged = null, onDeleted = null }) {
|
|
let checked = initial;
|
|
let exporting = false, categorizing = false;
|
|
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
|
|
let anchor = null, allMatching = false, touchMode = false, lastPointer = 'mouse', shiftBox = null;
|
|
const element = el('div');
|
|
element.id = 'bulkbar';
|
|
const bulk = el('div', undefined, 'bulkbar'), count = el('strong');
|
|
const selectMatching = createButton('');
|
|
scope.listen(selectMatching, 'click', () => {
|
|
allMatching = true;
|
|
render();
|
|
focusAfterOffer();
|
|
});
|
|
const setCategory = createButton('Set category');
|
|
scope.listen(setCategory, 'click', () => openCategory(setCategory));
|
|
const exportMetadata = createButton('Export metadata');
|
|
scope.listen(exportMetadata, 'click', () => { if (!allMatching) exportChecked(exportMetadata); });
|
|
const clearButton = createButton('Clear');
|
|
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
|
|
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
|
|
bulk.append(count);
|
|
if (activity) {
|
|
const addTags = createButton('Add tags');
|
|
scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged,
|
|
...matchingTarget() }));
|
|
const autoTag = createButton('Auto-tag selected'); // D85
|
|
scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged,
|
|
...matchingTarget() }));
|
|
bulk.append(addTags, autoTag);
|
|
}
|
|
bulk.append(setCategory);
|
|
if (collections) {
|
|
const addToCollection = createButton('Add to collection');
|
|
scope.listen(addToCollection, 'click', () => openAddToCollection({ scope, collections, images: checkedImages(),
|
|
trigger: addToCollection, ...collectionSelection(), onSourceChanged }));
|
|
bulk.append(addToCollection);
|
|
}
|
|
if (collections && collection) {
|
|
const removeButton = createButton('Remove from collection');
|
|
scope.listen(removeButton, 'click', () => removeChecked(removeButton));
|
|
bulk.append(removeButton);
|
|
}
|
|
bulk.append(exportMetadata);
|
|
// #480 (D159): the concept's "Remove links…" (`danger`) place, after Export metadata and before Clear.
|
|
if (onDeleted) {
|
|
const deleteButton = createButton('Delete…', 'danger');
|
|
scope.listen(deleteButton, 'click', () => openDeleteImages({ api, scope, store: activity, images: checkedImages(),
|
|
...matchingTarget(), trigger: deleteButton, onDeleted }));
|
|
bulk.append(deleteButton);
|
|
}
|
|
bulk.append(clearButton);
|
|
|
|
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
|
|
function render() {
|
|
if (!checked.size && !allMatching) { touchMode = false; bulk.remove(); return; }
|
|
const listing = getListing();
|
|
const label = allMatching ? matchingLabel(listing.total) : selectionLabel(checked.size);
|
|
if (count.textContent !== label) count.textContent = label;
|
|
// "Select all N matching" (D135) follows the count while every loaded image is checked and more match.
|
|
if (!allMatching && offersAllMatching(checked, loadedKeys(), listing)) {
|
|
const offer = matchingOffer(listing.total);
|
|
if (selectMatching.textContent !== offer) selectMatching.textContent = offer;
|
|
if (count.nextElementSibling !== selectMatching) count.after(selectMatching);
|
|
} else selectMatching.remove();
|
|
if (allMatching) {
|
|
exportMetadata.setAttribute('aria-disabled', 'true');
|
|
exportMetadata.title = EXPORT_LOADED_ONLY;
|
|
} else if (exportMetadata.title) {
|
|
exportMetadata.removeAttribute('title');
|
|
if (!exporting) exportMetadata.removeAttribute('aria-disabled');
|
|
}
|
|
if (bulk.parentNode !== element) element.append(bulk);
|
|
}
|
|
/** Every rendered checkbox shows the selection (cards mounted later read `isChecked`). */
|
|
function paint() {
|
|
for (const box of grid.querySelectorAll(CHECKBOX)) {
|
|
const on = isChecked(box.closest(CARD).dataset.imageKey);
|
|
if (box.checked !== on) box.checked = on;
|
|
}
|
|
}
|
|
function update(next) {
|
|
checked = next;
|
|
paint();
|
|
render();
|
|
}
|
|
/** Leaving "all N matching": the loaded images become the explicit selection the next change applies to. */
|
|
function settleMatching() {
|
|
if (!allMatching) return;
|
|
allMatching = false;
|
|
checked = Object.freeze(new Set(loadedKeys()));
|
|
}
|
|
function clear() {
|
|
anchor = null; touchMode = false;
|
|
if (!checked.size && !allMatching) return;
|
|
allMatching = false;
|
|
update(Object.freeze(new Set()));
|
|
}
|
|
/** Keep only the checked files `kept(key)` still lists (a refreshed result), so the count is what the actions apply to. */
|
|
function retain(kept) {
|
|
if (allMatching) { render(); return; }
|
|
const next = [...checked].filter(kept);
|
|
if (next.length === checked.size) return;
|
|
checked = Object.freeze(new Set(next));
|
|
render();
|
|
}
|
|
function loadedKeys() {
|
|
return getItems().map(imageKey);
|
|
}
|
|
function isChecked(key) {
|
|
return allMatching || checked.has(key);
|
|
}
|
|
function checkedImages() {
|
|
return allMatching ? getItems() : getItems().filter(image => checked.has(imageKey(image)));
|
|
}
|
|
/** The actions' images for "all N matching": the listing filter and its total, which the server resolves. */
|
|
function matchingTarget() {
|
|
return allMatching ? { filter: getFilter(), count: getListing().total } : {};
|
|
}
|
|
/** The collection actions' view of "all N matching" (#331 ruling): the loaded images and the match count. */
|
|
function collectionSelection() {
|
|
return { allMatching, total: allMatching ? getListing().total : checked.size };
|
|
}
|
|
/** Remove from collection: one write at a time; the selection stays (a reload keeps what is still listed). */
|
|
let removing = false;
|
|
async function removeChecked(button) {
|
|
if (removing) return;
|
|
removing = true;
|
|
const release = busy(button);
|
|
try {
|
|
await removeFromCollection({ scope, collections, collectionId: Number(collection), images: checkedImages(),
|
|
...collectionSelection(), onChanged: onCollectionsChanged, onSourceChanged });
|
|
} finally {
|
|
removing = false;
|
|
release();
|
|
}
|
|
}
|
|
/** Toggle one image on its own; it becomes the anchor. */
|
|
function toggle(key, on = !isChecked(key)) {
|
|
settleMatching();
|
|
anchor = key;
|
|
update(toggleSelection(checked, key, on));
|
|
}
|
|
/** The anchor's range to `key` takes `on`; without an anchor `key` is toggled on and becomes it. */
|
|
function range(key, on = true) {
|
|
settleMatching();
|
|
const keys = rangeKeys(loadedKeys(), anchor, key);
|
|
if (anchor === null || keys.length === 1) anchor = key;
|
|
update(applyKeys(checked, keys, on));
|
|
}
|
|
/** Shift+arrow keys: the range from the anchor (`from`, the card focus left, when there is none) to `to`. */
|
|
function extend(from, to) {
|
|
if (anchor === null || !loadedKeys().includes(anchor)) anchor = from;
|
|
range(to, true);
|
|
}
|
|
function selectAll() {
|
|
if (allMatching) return;
|
|
update(applyKeys(checked, loadedKeys(), true));
|
|
}
|
|
function focusAfterOffer() {
|
|
// The pressed offer left the bar: focus moves to the count's first action instead of the page.
|
|
(bulk.querySelector('button:not([aria-disabled="true"])') ?? focusFallback).focus({ preventScroll: true });
|
|
}
|
|
// Every card carries its image key; its only form control is the checkbox, so a toggle never opens or inspects.
|
|
scope.listen(grid, 'change', event => {
|
|
const box = event.target;
|
|
if (!box.matches(CHECKBOX)) return;
|
|
const key = box.closest(CARD).dataset.imageKey;
|
|
if (shiftBox === key) range(key, box.checked);
|
|
else toggle(key, box.checked);
|
|
shiftBox = null;
|
|
});
|
|
// D135: modified clicks select instead of opening, inspecting or starring. Captured, so the page's own click
|
|
// handler (images.mjs) never sees them; a plain click keeps its meaning.
|
|
grid.addEventListener('click', event => {
|
|
const card = event.target.closest(CARD);
|
|
if (!card) return;
|
|
const key = card.dataset.imageKey;
|
|
if (longPress.consumeClick()) { swallow(event); return; }
|
|
if (event.target.closest(CHECKBOX_LABEL)) {
|
|
// The box toggles natively; its change event applies the range.
|
|
if (event.shiftKey) shiftBox = key;
|
|
return;
|
|
}
|
|
// A keyboard click (Enter, Space: `detail` 0) keeps its plain meaning in touch selection mode.
|
|
const tapped = touchMode && lastPointer !== 'mouse' && event.detail !== 0;
|
|
if (event.shiftKey) { swallow(event); range(key, true); }
|
|
else if (event.ctrlKey || event.metaKey || tapped) { swallow(event); toggle(key); }
|
|
}, { capture: true, signal: scope.signal });
|
|
function swallow(event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
// A touch long-press selects the card and starts touch selection mode (D135).
|
|
const longPress = createLongPress({ onLongPress: key => {
|
|
touchMode = true;
|
|
anchor = key;
|
|
if (!isChecked(key)) toggle(key, true);
|
|
} });
|
|
scope.cleanup(() => longPress.cancel());
|
|
scope.listen(grid, 'pointerdown', event => {
|
|
lastPointer = event.pointerType || 'mouse';
|
|
shiftBox = null;
|
|
const card = event.target.closest(CARD);
|
|
if (lastPointer === 'mouse' || !card || !event.isPrimary) { longPress.cancel(); return; }
|
|
longPress.down(event.clientX, event.clientY, card.dataset.imageKey);
|
|
});
|
|
scope.listen(grid, 'pointermove', event => longPress.move(event.clientX, event.clientY));
|
|
for (const type of ['pointerup', 'pointercancel', 'pointerleave']) scope.listen(grid, type, () => longPress.up());
|
|
// A long-press's context menu (Android) would cover the selection it just started.
|
|
scope.listen(grid, 'contextmenu', event => {
|
|
if (lastPointer !== 'mouse' && (touchMode || longPress.pending())) event.preventDefault();
|
|
});
|
|
// Escape in the bar clears the selection; the bar hides, so focus moves to the page heading as after Clear.
|
|
scope.listen(element, 'keydown', event => {
|
|
if (event.key !== 'Escape' || (!checked.size && !allMatching)) return;
|
|
event.preventDefault();
|
|
clear();
|
|
focusFallback.focus({ preventScroll: true });
|
|
});
|
|
|
|
/** Busy until it settles: `aria-disabled` now, released in a `finally` so a throw cannot leave it stuck. */
|
|
function busy(button) {
|
|
button.setAttribute('aria-disabled', 'true');
|
|
return () => button.removeAttribute('aria-disabled');
|
|
}
|
|
/**
|
|
* Export metadata: the checked images' inspector envelopes in listing order, as one JSON download. A second press
|
|
* while it runs is ignored and the selection is kept. Leaving the page aborts the reads: no download, no toast.
|
|
*/
|
|
async function exportChecked(button) {
|
|
if (exporting) return;
|
|
exporting = true;
|
|
const release = busy(button);
|
|
try {
|
|
const { envelopes, failed } = await collectImagesMetadata(checkedImages(),
|
|
async image => toInspectionRecord(await api.get(inspectPath(image), { signal: scope.signal }), image));
|
|
if (scope.signal.aborted) return;
|
|
if (envelopes.length) downloadJson(BUNDLE_FILENAME, metadataBundle(envelopes, new Date().toISOString()));
|
|
showWorkspaceToast(exportOutcome(envelopes.length, envelopes.length + failed));
|
|
} finally {
|
|
exporting = false;
|
|
release();
|
|
}
|
|
}
|
|
/** A Blob, an anchor and a deferred revoke, as the concept's exportSample does. */
|
|
function downloadJson(filename, value) {
|
|
const url = URL.createObjectURL(new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }));
|
|
let timer, released = false;
|
|
const release = () => { if (released) return; released = true; clearTimeout(timer); URL.revokeObjectURL(url); };
|
|
scope.cleanup(release);
|
|
const anchor = el('a'); anchor.href = url; anchor.download = filename;
|
|
element.append(anchor); anchor.click(); anchor.remove();
|
|
timer = setTimeout(release, 1000);
|
|
}
|
|
/**
|
|
* Set category (app.js bulk-category → simpleInput, #332 D140): Apply sets each selected image's own category, linked
|
|
* or not; "Use prompt category" removes it after a confirmation that states the count. Up to 500 images (DC2): an
|
|
* explicit selection or "all N matching" (the listing filter, resolved by the server); more opens nothing.
|
|
*/
|
|
function openCategory(button) {
|
|
if (categorizing) return;
|
|
const count = allMatching ? getListing().total : checked.size;
|
|
const targets = allMatching ? {filter: getFilter()} : {items: bulkTargets(checkedImages())};
|
|
if (count > MAX_CATEGORY_IMAGES) { showWorkspaceToast(TOO_MANY); return; }
|
|
// Checked files the listing no longer holds (a refresh dropped them): nothing to send.
|
|
if (!allMatching && !targets.items?.length) { showWorkspaceToast(NONE_FOUND); return; }
|
|
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
|
|
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
|
|
secondary: { label: USE_PROMPT_CATEGORY, onClick: () => confirmRemove(button, targets, count) },
|
|
onApply: category => writeCategory(button, () => api.put(CATEGORY_URL, {...targets, category}),
|
|
result => setOutcome(result, category)) });
|
|
}
|
|
/** "Use prompt category": nothing is sent until Remove; Cancel leaves every category as it was. */
|
|
function confirmRemove(button, targets, count) {
|
|
const text = el('p', removeQuestion(count));
|
|
const actions = el('div', undefined, 'dialog-actions');
|
|
const cancel = createButton('Cancel'), remove = createButton(REMOVE_DIALOG.action, 'danger');
|
|
actions.append(cancel, remove);
|
|
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
|
remove.addEventListener('click', () => {
|
|
closeWorkspaceDialog();
|
|
writeCategory(button, () => api.del(CATEGORY_URL, {body: targets}), clearOutcome);
|
|
});
|
|
openContentDialog({ scope, trigger: button, title: REMOVE_DIALOG.title, nodes: [text, actions] });
|
|
cancel.focus();
|
|
}
|
|
/**
|
|
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
|
|
* not to save a visit that predates it). A failure keeps the selection, except a changed source, which reloads the
|
|
* listing as a success does (`onCategorySet`).
|
|
*/
|
|
async function writeCategory(button, send, outcome) {
|
|
categorizing = true;
|
|
const release = busy(button);
|
|
let result = null, failure = null;
|
|
try {
|
|
result = await send();
|
|
} catch (error) {
|
|
failure = error;
|
|
} finally {
|
|
categorizing = false;
|
|
release();
|
|
}
|
|
if (scope.signal.aborted) return;
|
|
const focused = document.activeElement === button;
|
|
if (failure !== null || !result) {
|
|
showWorkspaceToast(failureMessage(failure));
|
|
// A changed source reloads the listing (clearing the selection); any other failure keeps it.
|
|
if (failure?.code !== 'source-changed') return;
|
|
onCategorySet();
|
|
if (focused) focusFallback.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
showWorkspaceToast(outcome(result));
|
|
onCategorySet();
|
|
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
|
|
if (focused) focusFallback.focus({ preventScroll: true });
|
|
}
|
|
|
|
render();
|
|
return {
|
|
element,
|
|
isChecked,
|
|
// A saved visit keeps "all N matching" as its loaded images; the offer then shows again.
|
|
checked: () => (allMatching ? Object.freeze(new Set(loadedKeys())) : checked),
|
|
hasSelection: () => allMatching || checked.size > 0,
|
|
clear,
|
|
retain,
|
|
selectAll,
|
|
extend,
|
|
/** A loaded batch or a refreshed total: the count and the offer follow it. */
|
|
listingChanged: () => { if (checked.size || allMatching) render(); },
|
|
/** Whether a Set category write is still in flight. */
|
|
writing: () => categorizing,
|
|
};
|
|
}
|