Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a914da5d | ||
|
|
2941a9b748 | ||
|
|
505c16a724 | ||
|
|
66e567cc95 | ||
|
|
35b17a3b49 | ||
|
|
176558ea4d | ||
|
|
aa799d9606 | ||
|
|
ab6001917c | ||
|
|
264d9c3791 | ||
|
|
dc0df5760f | ||
|
|
982464b7f2 | ||
|
|
cf371094b7 | ||
|
|
02ce5e79bc | ||
|
|
81e1c685f0 | ||
|
|
f6e9de6483 | ||
|
|
3dfb828ae5 | ||
|
|
1a90d3b0ae | ||
|
|
b7cf8ef221 | ||
|
|
6b568de09b | ||
|
|
b107de70f8 | ||
|
|
a6d634509b | ||
|
|
e137270f45 | ||
|
|
4b4fa7f8ff | ||
|
|
4a90a5dd83 | ||
|
|
e3191853c8 | ||
|
|
9bf8362d22 | ||
|
|
a90d251f39 | ||
|
|
a8e56f1812 | ||
|
|
3bba78d0eb | ||
|
|
2d7e138624 | ||
|
|
e34c1e2351 | ||
|
|
68443551b2 |
+92
-15
@@ -1386,8 +1386,8 @@ The Images page renders the concept's DOM with real data. Its controls, top to b
|
||||
- **Toolbar** (`toolbar.mjs`, shared with the Library in PR E): `#search` "Search
|
||||
prompts, tags, filenames…" (debounced 250 ms; filename, linked positive text and
|
||||
Library title, and tag names), `#category-filter` "All categories" plus the
|
||||
Library's categories, `#sort` (Recently added, Name A–Z, Highest rated, then Oldest
|
||||
first and Name Z–A), and `Filters` (opens "Refine this view").
|
||||
Library's categories, `#sort` (Recently added, Name A–Z, then Oldest first and Name
|
||||
Z–A; no Highest rated, D153), and `Filters` (opens "Refine this view").
|
||||
- **Filter row**: Grid / List / Masonry (`.segmented`, group "Image layout"),
|
||||
"Thumbnail size" Comfortable / Large / Compact, the scope chip ("All output folders"
|
||||
or "<source> · <folder>"), and "Group by positive prompt →" (goes to the Library).
|
||||
@@ -1413,7 +1413,7 @@ How the earlier workspace Images filters moved (old → new). The legacy `/galle
|
||||
| Source | "Refine this view" → Source (D14); shown in the scope chip |
|
||||
| Folder | "Refine this view" → Folder (D14); shown in the scope chip |
|
||||
| Media (All media / Images / Videos) | "Refine this view" → Media (D14) |
|
||||
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A, plus Highest rated (D13, D14) |
|
||||
| Sort: Newest first / Oldest first / Filename A–Z / Filename Z–A | toolbar sort: Recently added / Oldest first / Name A–Z / Name Z–A (D13, D14; no Highest rated, D153) |
|
||||
| (none) | toolbar category (the effective category, D141) and "Refine this view" → Tags, through the linked positive (D13, D14) |
|
||||
| Refresh | Import → "Scan an output folder" → Rescan (D15) |
|
||||
| Grid / List / Masonry, Comfortable / Large / Compact | the same choices in the concept filter row |
|
||||
@@ -2404,7 +2404,7 @@ The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
`article.prompt-card` (app.js `renderLibrary` a586, style.css s653–708): `.card-art`
|
||||
(at most two previews), `.card-content` (`.card-title` with the title `button` and the
|
||||
category `span.chip`, the positive prompt block, `.card-meta`, `.tags`) and
|
||||
`.card-footer` (the rating and last-use line, then `.row-actions` with the positive
|
||||
`.card-footer` (the last-use line, then `.row-actions` with the positive
|
||||
`button.send.icon-btn` and "Explore history"). Rows stay windowed (`.library-row`).
|
||||
`tests/browser/workspace-library.spec.mjs` covers the markup, the clamp, the buttons and
|
||||
the measured row heights on Chromium and touch.
|
||||
@@ -2414,8 +2414,8 @@ Recorded deviations on this page:
|
||||
"Used today", "Used yesterday", "Used N days ago" (under a week) or "Used <Mon D>"
|
||||
("Used <Mon D>, <YYYY>" for a use in another calendar year, so last year's date never
|
||||
reads as this year's), counted in the viewer's local calendar days from `last_used_at` (a time ahead of the
|
||||
browser reads as today), and "Not used yet" without one. Stars are `★` × rating with
|
||||
a visually hidden "Rated N out of 5"; an unrated parent shows only the last-use words.
|
||||
browser reads as today), and "Not used yet" without one. There are no rating stars
|
||||
(D153, amending D23): every parent shows only the last-use words.
|
||||
The meta chip reads "− N negative variants" (singular for one). The title and Explore
|
||||
history are buttons that open `#/library/<id>`, as the concept's are; the trade-off
|
||||
against the old links is that they offer no open in a new tab, middle-click or URL on
|
||||
@@ -2576,6 +2576,48 @@ during a write); `tests/browser/library-concept.spec.mjs` (the bar comparison no
|
||||
Add tags); units `tests/js/workspace-library-selection.test.js`; Python
|
||||
`tests/test_bulk_library_tags.py`. A Python change: ComfyUI needs a restart.
|
||||
|
||||
### No 1–5 prompt rating in the workspace (#447)
|
||||
|
||||
**D153** (maintainer ruling 2026-10-28: the star is only for favorites; the concept keeps
|
||||
a 1–5 rating): the workspace shows and edits no prompt rating. The concept's rating
|
||||
appears in four places, and production draws none of them:
|
||||
|
||||
- Library cards: the concept's footer reads "★★★★★ · Used today"; production reads only
|
||||
the last use (amends D23). `library-concept.spec.mjs` takes the stars and their " · "
|
||||
out of the concept page itself before measuring (`dropConceptRatings`), so the footer
|
||||
facts are compared at production's width; it first checks that every concept card had
|
||||
them.
|
||||
- Sort: the concept's "Highest rated" is not offered on Images or the Library (amends
|
||||
D14); the comparisons drop it from the concept's options. A saved view or a Library
|
||||
visit that still names `sort: "rating"` opens on Recently added; the server keeps such a
|
||||
view as it was stored.
|
||||
- Editor (New prompt, Edit recipe metadata, Create text revision): no `#recipe-rating`
|
||||
"Rating" select; `library-editor-canonical-comparison.mjs` `mapConcept` drops the
|
||||
concept's Rating field. The editor never sends `rating`. An edit sends only changed
|
||||
fields, so a stored rating stays as it is; a new prompt and a text revision create an
|
||||
unrated positive (the revised prompt keeps its own rating; the legacy admin page shows
|
||||
the revision as unrated). A reuse that leaves metadata unapplied compares title,
|
||||
category, tags and notes only. The edit token still covers `rating`, so a rating
|
||||
changed on a legacy page while the editor is open is an ordinary metadata conflict
|
||||
whose review shows no visible difference.
|
||||
- System > Statistics: no "Avg. rating" column and no "Rated images" total (amends D96);
|
||||
`system-concept.spec.mjs` maps the concept's table without that column.
|
||||
|
||||
Data and API are unchanged: the `rating` column (`CHECK` 1–5), `PUT
|
||||
/prompt_manager/prompts/{id}/rating`, `rating` in Library, Images and statistics
|
||||
answers, and the server's `rating` sorts. The legacy pages keep their ratings until the
|
||||
cutover (#334). Favorites (the image star) are unchanged.
|
||||
|
||||
Tests: units `tests/js/workspace-library-editor-state.test.js` (a prompt rated 4 edited
|
||||
and saved keeps 4, no `rating` in any body, no rating field),
|
||||
`workspace-library-state.test.js`, `workspace-images-state.test.js`,
|
||||
`workspace-saved-views.test.js`, `workspace-system-overview.test.js`,
|
||||
`workspace-library-format.test.js`; browser `workspace-library-editor.spec.mjs` (the
|
||||
fixture's positive stored with rating 4 still reads 4 after Edit, a revision, Add
|
||||
negative and a reuse), `workspace-library.spec.mjs`, `workspace-images.spec.mjs`,
|
||||
`workspace-saved-views.spec.mjs`, `workspace-system.spec.mjs` and the concept
|
||||
comparisons above.
|
||||
|
||||
### Prompt history page and recorded deviations (#249 PR F-a)
|
||||
|
||||
The history page (`#/library/<id>`) takes the concept's heading, summary, negative
|
||||
@@ -2769,10 +2811,12 @@ mapped differences"):
|
||||
- **D60** (heading action, LI-3): "Open file metadata" (`ca-primary`) replaces the
|
||||
concept's "Open sample metadata". It is `aria-disabled="true"` and
|
||||
`aria-describedby="local-file-note"` (the drop note) until a classified selection is
|
||||
ready, then opens the shared viewer on that file.
|
||||
ready, then opens the shared viewer on that file. Amended by D155: choosing the file
|
||||
already opens it; the action reopens it.
|
||||
- **D61** (copy, LI-3): the surface `p` reads "Read embedded metadata in this browser.
|
||||
The file is not uploaded or added to your library."; the drop note "Your file stays in
|
||||
this browser. Its metadata opens with “Open file metadata”."; the chooser label "Choose
|
||||
this browser. Its metadata opens with “Open file metadata”." (D155: "Your file stays in
|
||||
this browser. Choosing it opens its metadata."); the chooser label "Choose
|
||||
an image or video for a local preview"; the toast "Local preview only. No file
|
||||
uploaded; metadata is available above.". The h2 "Inspect without importing" is the
|
||||
concept's, verbatim.
|
||||
@@ -2826,6 +2870,7 @@ mapped differences"):
|
||||
by the next selection attempt or after the concept's toast interval (6.5 s). The toast
|
||||
"Local preview only…; metadata is available above." shows only for a selection whose
|
||||
status is empty or the AVI/WMV note, never beside an unreadable or failed preview.
|
||||
Amended by D155: only the AVI/WMV note keeps the toast; an empty status opens the viewer.
|
||||
- **D64** (drop, LI-6): `dragover`/`drop` on `.file-drop` accept exactly one File, read
|
||||
only `dataTransfer.files`/`items` (never its strings) and change nothing visually.
|
||||
While the page is mounted, `window` `dragover`/`drop` call `preventDefault`, so a missed
|
||||
@@ -2838,6 +2883,36 @@ mapped differences"):
|
||||
Modified with no Path row and no Show in folder, and ends with "Local file; no saved
|
||||
library association was checked." and Download original. There is no Prompt history
|
||||
action and no category or tag chips.
|
||||
- **D155** (#448, maintainer choice 2026-10-28, beyond the concept): choosing or dropping
|
||||
a file opens the shared viewer on it at once, with `#viewer-details` showing, in one
|
||||
step; the concept previews it and waits for its heading action. The viewer opens once
|
||||
per selection, after the session has selected the record (a microtask after `ready`),
|
||||
only while that selection is current and no other dialog is open. A new selection
|
||||
that starts while the viewer shows the previous file closes it first, so no element
|
||||
holds the old `blob:` URL when the session revokes it (`URLs and media end with their
|
||||
owning session`); the new file's viewer opens once it is ready. A video opens the
|
||||
viewer only once its inline preview has settled (metadata, an error, or D62's 15 s):
|
||||
replacing a video while both the inline and the viewer's video were still starting
|
||||
deadlocked WebKit (`a video replaced while still starting is reset once it settles`
|
||||
hung 2 of 2 runs before, passes 5 of 5 after). It opens for a selection with no status and for one
|
||||
without a preview URL ("The preview could not be displayed. Metadata and the original
|
||||
remain available in the viewer."); an unreadable file stays on the page with its
|
||||
alert, and AVI/WMV keeps its note and the toast (the viewer could not play it and
|
||||
reads no metadata); "Open file metadata" opens either. An image or video that fails to
|
||||
decode after the viewer opened shows the viewer's own media error; the page's alert
|
||||
is there on close. The toast no longer shows for an opened file (the viewer is the
|
||||
confirmation), and the drop note reads "Your file stays in this browser. Choosing it
|
||||
opens its metadata.". On this page the viewer, by choice or by the heading action,
|
||||
opens with the details showing even when "Collapse viewer details by default" is on
|
||||
(D97); the toggle is still drawn then and starts as "Hide details" (`aria-expanded`
|
||||
true). Closing returns focus to the chooser (the heading action when it opened the
|
||||
viewer), with the preview, the chooser naming the file and the heading action in
|
||||
place; the next file chosen or dropped replaces it and opens again. A drop is still
|
||||
ignored while the viewer is open (D64). Nothing else changes: the file is read in this
|
||||
browser only. Browser tests: "D155: choosing a file opens the viewer with its details
|
||||
in one click", "D155: Inspect shows the details even when the viewer collapses them by
|
||||
default (amends D97)", "D155: an unreadable file stays on the page with its alert" and
|
||||
"D155: at 390px the opened details are reachable below the media".
|
||||
- **D117** (Copy settings summary, LI-4): the legacy metadata
|
||||
page's COPY ALL, consolidated into the shared full inspector (the viewer's
|
||||
`#viewer-details`, so Images, the recorded history's current file and Inspect all have
|
||||
@@ -3491,12 +3566,13 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
every check passed, some could not run, or issues need review. Close / Review missing
|
||||
links, which reviews a fresh Missing-file links plan (D100, S7); it never cleans up.
|
||||
Nothing is changed by a check.
|
||||
- **D96** (Statistics): "Usage statistics" without the sample note: the concept's
|
||||
"Runs by category" table (Category / Runs / Avg. rating) with the real runs and
|
||||
average rating of eligible parents per category ("Uncategorized", "Not rated"),
|
||||
then a second table "Library totals" (Total / Count: Prompts, Tags, Images from the
|
||||
legacy statistics, and Rated images: indexed images whose linked prompt is rated).
|
||||
The Overview row says "Runs and ratings by category, with library totals." because
|
||||
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
|
||||
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
|
||||
with the real runs of eligible parents per category ("Uncategorized"), then a second
|
||||
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
|
||||
statistics; no Rated images). The API still reports `average_rating` and
|
||||
`rated_images`; the workspace does not show them.
|
||||
The Overview row says "Runs by category, with library totals." because
|
||||
that is what the dialog shows (the concept's own dialog shows only "Runs by
|
||||
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
|
||||
table of them exists yet. A failed read toasts "Statistics could not
|
||||
@@ -3515,7 +3591,8 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
|
||||
card's title and file line, Info and Tag stay at the end of the row), "Collapse viewer
|
||||
details by default" (`sidebarCollapsedByDefault`; the viewer opens with
|
||||
`#viewer-details` hidden and a "Show details"/"Hide details" toggle with
|
||||
`aria-expanded`, the stage taking the whole width), and the operation "Reset display
|
||||
`aria-expanded`, the stage taking the whole width; amended by D155: on Inspect a file
|
||||
the viewer opens with the details showing and the toggle reading "Hide details"), and the operation "Reset display
|
||||
defaults" ("Restore this browser’s display defaults, then save. Other settings are
|
||||
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
|
||||
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
|
||||
|
||||
@@ -92,7 +92,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D153 (#447): production offers no "Highest rated".
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
|
||||
|
||||
@@ -9,7 +9,7 @@ import { parityPng } from './local-inspect-files.mjs';
|
||||
// `#metadata` (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729, style.css 709–722,
|
||||
// 1180–1194, 1543–1545, 1559–1564), at 1600x1100 and 390x844 in both themes, in three states: initial, selected (the
|
||||
// same synthetic PNG set into both `#local-file` inputs) and viewer-open. The concept is never edited: the recorded
|
||||
// differences D60–D66 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
|
||||
// differences D60–D66 and D155 (TESTING.md) are applied to its measurement, and, where they change copy, to its page before it
|
||||
// is measured, so the browser lays the concept out with production's words. After that the diff must be empty.
|
||||
// Paired captures are artifacts only; there are no PNG baselines.
|
||||
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
|
||||
@@ -22,7 +22,8 @@ const COPY = {
|
||||
'Read embedded metadata in this browser. The file is not uploaded or added to your library.'],
|
||||
chooser: ['Choose an image for a local preview', 'Choose an image or video for a local preview'],
|
||||
note: ['This mock previews your file locally. Sample metadata is available with “Open sample metadata”.',
|
||||
'Your file stays in this browser. Its metadata opens with “Open file metadata”.'],
|
||||
// D155 (#448): choosing the file opens its metadata.
|
||||
'Your file stays in this browser. Choosing it opens its metadata.'],
|
||||
toast: ['Local preview only. No file uploaded; sample metadata is available above.',
|
||||
'Local preview only. No file uploaded; metadata is available above.'],
|
||||
};
|
||||
@@ -212,23 +213,29 @@ async function productionStates(page, theme, testInfo, tag) {
|
||||
await settle(page);
|
||||
const initial = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-initial-${tag}`);
|
||||
// D155 (#448): choosing the file opens the viewer with its details at once (the concept waits for its heading
|
||||
// action), so the viewer state is measured first; the selected page is measured after Escape, with the focus the
|
||||
// close returned to the chooser removed (the concept's page has no focused control there).
|
||||
await page.locator('#workspace-main #local-file').setInputFiles(parityPng(FILE));
|
||||
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(COPY.toast[1]);
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'false');
|
||||
// D63: a readable PNG shows no status line.
|
||||
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
|
||||
await action.click();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .panel-heading')).toBeVisible();
|
||||
await expect(page.locator('dialog#viewer-dialog[open] #viewer-details .meta-grid').first()).toBeVisible();
|
||||
await page.mouse.move(0, 0);
|
||||
await settle(page);
|
||||
const viewer = await page.evaluate(measureViewer);
|
||||
await captureCanonicalState(page, testInfo, `production-viewer-${tag}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('dialog#viewer-dialog')).toHaveCount(0);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await expect(page.locator('#workspace-main #local-preview img.local-file-preview')).toBeVisible();
|
||||
await page.locator('#workspace-main #local-preview img').evaluate(image => image.decode().catch(() => {}));
|
||||
// D155: no toast; the opened viewer was the confirmation.
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
await expect(action).toHaveAttribute('aria-disabled', 'false');
|
||||
// D63: a readable PNG shows no status line.
|
||||
await expect(page.locator('#workspace-main #local-preview p.small')).toHaveCount(0);
|
||||
await settle(page);
|
||||
const selected = await page.evaluate(measureInspect, '#workspace-main');
|
||||
await captureCanonicalState(page, testInfo, `production-selected-${tag}`);
|
||||
return { initial, selected, viewer };
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ export function mapLibraryConcept(reference) {
|
||||
// Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...library.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>{
|
||||
// D14: the Library appends its Oldest first after the concept's three sorts.
|
||||
if(item.id==='sort')return {...item,options:[...item.options,'Oldest first']};
|
||||
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
|
||||
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
|
||||
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
|
||||
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
|
||||
return item;
|
||||
@@ -39,11 +39,11 @@ export function mapLibraryConcept(reference) {
|
||||
title:{...card.title,items:card.title.items.map(item=>item.tag==='button'?{...item,minWidth:'0px'}:item)},
|
||||
// D43: tag chips come in the store's alphabetical order; the row flows again from the concept's own chips.
|
||||
tags:sortedTags(card.tags),
|
||||
// D23: the stars are aria-hidden, with a visually hidden "Rated N out of 5" spoken in their place. The fixture's
|
||||
// run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own "4 runs"
|
||||
// and "Used today". The two-line clamp is
|
||||
// D153 (#447): no rating stars; the concept page itself is measured without them (`dropConceptRatings`). The
|
||||
// fixture's run_count (4) and last_used_at (its own "now", where the page's clock stands) give the concept's own
|
||||
// "4 runs" and "Used today". The two-line clamp is
|
||||
// applied below through `clampedCards`, measured on the concept with `CLAMP_CSS` (its samples wrap at 390px).
|
||||
footer:{...card.footer,facts:{...card.footer.facts,spoken:card.footer.facts.spoken.replace(/^(★+)/,stars=>`Rated ${stars.length} out of 5`)}}}));
|
||||
footer:card.footer}));
|
||||
return {...reference,library:{...library,toolbar,
|
||||
// D35: the caption uses the singular for one.
|
||||
caption:{...library.caption,top:library.caption.top-rise,spans:library.caption.spans.map(span=>({...span,text:countCopy(span.text)}))},
|
||||
@@ -139,9 +139,20 @@ async function sortConceptTags(page) {
|
||||
return ()=>page.evaluate(()=>{for(const [row,chips] of window.__conceptTagOrder)row.append(...chips);delete window.__conceptTagOrder;});
|
||||
}
|
||||
|
||||
/**
|
||||
* D153 (#447): production draws no 1–5 rating. The concept's card footers read "★★★★★ · Used today"; the stars and
|
||||
* their separator are taken out of the concept page itself, so the footer facts are measured as production draws them.
|
||||
* Returns the concept's original footer texts.
|
||||
*/
|
||||
async function dropConceptRatings(page) {
|
||||
return page.evaluate(()=>[...document.querySelectorAll('.prompt-card .card-footer > span')].map(span=>{
|
||||
const original=span.textContent;span.textContent=original.replace(/^★+ · /,'');return original;}));
|
||||
}
|
||||
async function openConceptLibrary(page,theme,motion) {
|
||||
await openConcept(page,'library',theme,motion);
|
||||
await expect(page.locator(CARDS)).toHaveCount(CONCEPT_PROMPTS);
|
||||
const footers=await dropConceptRatings(page);
|
||||
expect(footers,'the concept rates every card (D153 removes it)').toEqual(Array(CONCEPT_PROMPTS).fill().map((_,index)=>expect.stringMatching(/^★+ · \S/)));
|
||||
await page.mouse.move(0,0);await decoded(page);await settle(page);
|
||||
}
|
||||
async function openProductionLibrary(page,theme,motion) {
|
||||
@@ -295,7 +306,7 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`library geomet
|
||||
expect.soft(card.meta,`${motion} concept meta`).toMatchObject({fontSize:'10px',columnGap:'9px'});
|
||||
expect.soft(card.meta.items.map(item=>item.text),`${motion} concept meta text`).toEqual(['− 3 negative variants','4 runs','4 images']);
|
||||
expect.soft(card.footer,`${motion} concept footer`).toMatchObject({paddingTop:'11px',paddingRight:'18px',paddingBottom:'11px',paddingLeft:'18px',borderTopWidth:'1px',borderTopStyle:'solid'});
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts`).toBe('★★★★★ · Used today');
|
||||
expect.soft(card.footer.facts.text,`${motion} concept footer facts (D153: without the stars)`).toBe('Used today');
|
||||
expect.soft(diffInventory(stripExact(cards.map(item=>sortedTags(item.tags))),stripExact(sorted)),`${motion} tags in alphabetical order`).toEqual([]);
|
||||
expect.soft(diffInventory(stripExact(clampedCards(reference.library)),stripExact({grid:clamped.grid,cards:clamped.cards})),`${motion} clamped cards`).toEqual([]);
|
||||
expect.soft(diff,`${motion} differences after the recorded deviations`).toEqual([]);
|
||||
@@ -394,7 +405,7 @@ test('a run that crosses midnight still reads the concept fixture\'s day',async(
|
||||
const reference=await conceptInventory(page);
|
||||
await openProductionLibrary(page,'dark','no-preference');
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
expect(actual.library.cards.map(card=>card.footer.facts.text.replace(/^★+ · /,''))).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
|
||||
expect(actual.library.cards.map(card=>card.footer.facts.text)).toEqual(Array(CONCEPT_PROMPTS).fill('Used today'));
|
||||
expect(compare(reference,actual,{sections:['library'],library:['grid','cards','trailing']})).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ export function inventoryOf(dialog) {
|
||||
demo:Boolean(dialog.querySelector('.demo-note')),
|
||||
};
|
||||
}
|
||||
// Documented differences only: prototype Save label, prototype demo note, and data-dependent values.
|
||||
// Documented differences only: prototype Save label, prototype demo note, data-dependent values, and D153 (#447): the
|
||||
// concept's "Rating" select is not drawn.
|
||||
export function mapConcept(reference) {
|
||||
return {...reference,demo:false,actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
|
||||
return {...reference,demo:false,fields:reference.fields.filter(item=>item.label!=='Rating'),actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Save in prototype'?{...item,text:'Save'}:item)}};
|
||||
}
|
||||
|
||||
// Flatten an inventory to path->value so a comparison reports exactly which leaves differ.
|
||||
|
||||
@@ -162,7 +162,6 @@ async function serverJson(request, url) {
|
||||
}
|
||||
|
||||
const number = value => value.toLocaleString('en-US');
|
||||
const ratingText = value => value === null ? 'Not rated' : `${number(value)} / 5`;
|
||||
|
||||
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
|
||||
function mapOverviewConcept(reference, overview) {
|
||||
@@ -178,8 +177,8 @@ function mapOverviewConcept(reference, overview) {
|
||||
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
|
||||
// D93: the real sources, not samples.
|
||||
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
|
||||
// D96: the dialog shows runs and ratings by category and library totals, not prompt, tag or date usage.
|
||||
Statistics: row => ({ ...row, description: 'Runs and ratings by category, with library totals.' }),
|
||||
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
|
||||
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
|
||||
};
|
||||
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
|
||||
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
|
||||
@@ -288,7 +287,10 @@ test('system comparison detects mutations', async ({ page, request }) => {
|
||||
expect(diffInventory(expected, await measure())).toEqual([]);
|
||||
});
|
||||
|
||||
/** D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. */
|
||||
/**
|
||||
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
|
||||
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
|
||||
*/
|
||||
function mapStatisticsConcept(reference, statistics) {
|
||||
const [runs] = reference.tables;
|
||||
return {
|
||||
@@ -296,10 +298,11 @@ function mapStatisticsConcept(reference, statistics) {
|
||||
// No sample note (prototype copy); a second table follows the first.
|
||||
body: ['table', 'table'],
|
||||
tables: [
|
||||
{ ...runs, rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs), ratingText(row.average_rating)]) },
|
||||
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
|
||||
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
|
||||
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
|
||||
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
|
||||
['Images', number(statistics.totals.total_images)], ['Rated images', number(statistics.rated_images)]] },
|
||||
['Images', number(statistics.totals.total_images)]] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -363,14 +363,26 @@ test('a list read superseded by another read still opens a collection made since
|
||||
const fresh = (await added.json()).collection.id;
|
||||
await expect(rows(page)).toHaveCount(2);
|
||||
const hold = await holdListReads(page);
|
||||
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
|
||||
// supersedes that one while both are held.
|
||||
// A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The
|
||||
// timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so
|
||||
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
|
||||
// hash change, and the page would then wait on the timer's read alone (#459).
|
||||
let releaseRename;
|
||||
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
|
||||
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
|
||||
if (route.request().method() !== 'PATCH') return route.fallback();
|
||||
await renameHeld;
|
||||
return route.fallback();
|
||||
});
|
||||
await editButton(page).click();
|
||||
await nameField(page).fill('Landscape grids');
|
||||
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Collection renamed.');
|
||||
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
|
||||
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
|
||||
await expect.poll(() => hold.held.length).toBe(1);
|
||||
releaseRename();
|
||||
// The rename landed (the sidebar row has the new name), so the count read is now due.
|
||||
await expect(row(page, 'Landscape grids')).toHaveCount(1);
|
||||
await expect.poll(() => hold.held.length).toBe(2);
|
||||
await expect(main(page)).not.toContainText('Collections could not be loaded.');
|
||||
await hold.release();
|
||||
await expect(heading(page)).toHaveText('Fresh');
|
||||
|
||||
@@ -897,7 +897,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
// app.js toolbar() a347–360: search, category, sort, Save view, Filters.
|
||||
await expect(main.locator('search.toolbar > *')).toHaveCount(5);
|
||||
await expect(main.locator('search.toolbar > button')).toHaveText(['Save view', 'Filters']);
|
||||
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Highest rated', 'Oldest first', 'Name Z–A']);
|
||||
// D153 (#447): no "Highest rated", even with a rated prompt in the library.
|
||||
await expect(sortBy(page).locator('option')).toHaveText(['Recently added', 'Name A–Z', 'Oldest first', 'Name Z–A']);
|
||||
const row = main.locator('.filter-row');
|
||||
await expect(row.locator(':scope > *')).toHaveCount(5);
|
||||
await expect(row.locator(':scope > .segmented[role="group"][aria-label="Image layout"] > button')).toHaveText(['Grid', 'List', 'Masonry']);
|
||||
@@ -916,8 +917,8 @@ test('images toolbar and filter row match the concept controls', async ({ page,
|
||||
await search(page).fill('');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await sortBy(page).selectOption('rating');
|
||||
await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png');
|
||||
await sortBy(page).selectOption('name-desc');
|
||||
await expect(sortBy(page)).toHaveValue('name-desc');
|
||||
await sortBy(page).selectOption('newest');
|
||||
await page.getByRole('combobox', { name: 'Filter by category', exact: true }).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
|
||||
@@ -8,10 +8,11 @@ import { parityPng, localFixture, tinyWebm, sendablePng, corruptPng, disguised,
|
||||
|
||||
const workspace = '/prompt_manager/workspace';
|
||||
const TOAST = 'Local preview only. No file uploaded; metadata is available above.';
|
||||
const NO_TOAST = '';
|
||||
const ACTION = 'Open file metadata';
|
||||
const COPY = {
|
||||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||||
note: 'Your file stays in this browser. Choosing it opens its metadata.',
|
||||
chooser: 'Choose an image or video for a local preview',
|
||||
multiple: 'Drop one file at a time.',
|
||||
directory: 'Folders cannot be inspected. Choose one image or video file.',
|
||||
@@ -53,13 +54,26 @@ async function choose(page, file) {
|
||||
await chooser(page).setInputFiles(file);
|
||||
}
|
||||
|
||||
/** D155: choosing a file opens the viewer on it with its details; waits for that. */
|
||||
async function openViewer(page) {
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await action(page).click();
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(rail(page).locator('.panel-heading')).toBeVisible();
|
||||
}
|
||||
|
||||
/** After the viewer was closed, the heading action (D60) opens it again on the current selection. */
|
||||
async function reopenViewer(page) {
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await action(page).click();
|
||||
await openViewer(page);
|
||||
}
|
||||
|
||||
/** Waits for the viewer a selection opened (D155) and closes it, back on the page. */
|
||||
async function closeOpened(page) {
|
||||
await openViewer(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Dispatches dragover + drop on `selector` with Files built in the page (and optional string items). */
|
||||
function drop(page, selector, { files = [], strings = [] } = {}) {
|
||||
return page.evaluate(async ({ selector, files, strings }) => {
|
||||
@@ -152,7 +166,8 @@ test('chooser opens the shared full inspector without a second read', async ({ p
|
||||
const box = await image.evaluate(node => ({ height: node.getBoundingClientRect().height, fit: getComputedStyle(node).objectFit, margin: getComputedStyle(node).marginTop }));
|
||||
expect(box.height).toBeLessThanOrEqual(400);
|
||||
expect(box).toMatchObject({ fit: 'contain', margin: '20px' });
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
// D155: the open viewer is the confirmation, so no toast.
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(action(page)).not.toHaveAttribute('aria-describedby', /.*/);
|
||||
|
||||
@@ -195,18 +210,120 @@ test('chooser opens the shared full inspector without a second read', async ({ p
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(action(page)).toBeFocused();
|
||||
// D155: focus returns to the chooser that opened it.
|
||||
await expect(chooser(page)).toBeFocused();
|
||||
// Opening the viewer again shows the same record without reading the File a second time.
|
||||
await page.evaluate(() => {
|
||||
window.__slices = 0;
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) { if (this.name === 'f02.png') window.__slices++; return slice.apply(this, args); };
|
||||
});
|
||||
await openViewer(page);
|
||||
await reopenViewer(page);
|
||||
expect(await railContent(rail(page))).toEqual(served);
|
||||
expect(await page.evaluate(() => window.__slices)).toBe(0);
|
||||
});
|
||||
|
||||
// #448 (D155): choosing or dropping a file opens the shared viewer with its details showing, in one step. Closing it
|
||||
// returns to the page with the file still chosen and focus on the chooser, ready for the next file.
|
||||
test('D155: choosing a file opens the viewer with its details in one click', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(rail(page)).toBeVisible();
|
||||
await expect(rail(page).locator('.prompt-section.positive p.prompt-text')).toHaveText('a red fox in snow, detailed fur');
|
||||
await expect(viewer(page).getByRole('button', { name: /^(Show|Hide) details$/ })).toHaveCount(0);
|
||||
// The open viewer is the confirmation: no toast says the metadata is "available above".
|
||||
await expect(page.locator('#workspace-toast')).toHaveText('');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(chooser(page)).toBeFocused();
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
// A dropped second file replaces the first and opens at once.
|
||||
await drop(page, '.file-drop', { files: [sendablePng()] });
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('send-me.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await expect(chooser(page)).toBeFocused();
|
||||
// The same file again is a new selection and opens again; the heading action reopens the current one.
|
||||
expect(await chooser(page).evaluate(node => { node.dispatchEvent(new MouseEvent('click')); return node.value; })).toBe('');
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await action(page).click();
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(rail(page)).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(action(page)).toBeFocused();
|
||||
});
|
||||
|
||||
test('D155: Inspect shows the details even when the viewer collapses them by default (amends D97)', async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem('gallerySettings', JSON.stringify({ sidebarCollapsedByDefault: true })));
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
await expect(rail(page)).toBeVisible();
|
||||
const toggle = viewer(page).getByRole('button', { name: 'Hide details', exact: true });
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await toggle.click();
|
||||
await expect(rail(page)).toBeHidden();
|
||||
await page.keyboard.press('Escape');
|
||||
await action(page).click();
|
||||
await expect(rail(page)).toBeVisible();
|
||||
await expect(viewer(page).getByRole('button', { name: 'Hide details', exact: true })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('D155: an unreadable file stays on the page with its alert', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const slice = Blob.prototype.slice;
|
||||
Blob.prototype.slice = function (...args) {
|
||||
if (this.name === 'gone.png') return { arrayBuffer: () => Promise.reject(new DOMException('The file is gone', 'NotReadableError')) };
|
||||
return slice.apply(this, args);
|
||||
};
|
||||
});
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png', 'gone.png'));
|
||||
await expect(status(page)).toHaveText('Embedded metadata could not be read.');
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('D155: an AVI keeps its note and toast and does not open the viewer', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
// AVI and WMV metadata is never read and the browser cannot play them, so the page keeps the selection's note.
|
||||
const avi = { name: 'clip.avi', mimeType: 'video/x-msvideo', buffer: Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('AVI LIST'), Buffer.alloc(64)]) };
|
||||
await choose(page, avi);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await reopenViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('clip.avi');
|
||||
});
|
||||
|
||||
test('D155: a selection that lands while another dialog is open leaves the viewer closed', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
await page.evaluate(() => { const other = document.createElement('dialog'); other.id = 'other-dialog'; other.textContent = 'Other'; document.body.append(other); other.showModal(); });
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await page.evaluate(() => document.querySelector('#other-dialog').close());
|
||||
await reopenViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
});
|
||||
|
||||
test('D155: at 390px the opened details are reachable below the media', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(viewer(page)).toBeVisible();
|
||||
const facts = rail(page).locator('.meta-grid').first();
|
||||
await facts.scrollIntoViewIfNeeded();
|
||||
await expect(facts).toBeInViewport();
|
||||
expect(await viewer(page).evaluate(node => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
// PR L3 Task 10: local roles are the server inspector's (local-resolver.mjs), so for every parity PNG the rail shows the
|
||||
// prompts, their status lines and the facts Images shows for the same bytes in the output folder.
|
||||
const PARITY_PNGS = ['f01b', 'f02', 'f03', 'f06a', 'f06b', 'f06c', 'f06d', 'f06e', 'f07a', 'f07b', 'f08', 'f09', 'f10', 'f11', 'f12',
|
||||
@@ -260,7 +377,9 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||||
await expect(preview(page)).toBeEmpty();
|
||||
expect(await drop(page, '.file-drop', { files: [parityPng('f02.png')] })).toEqual({ over: true, drop: true });
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(TOAST);
|
||||
// D155: the drop opened the viewer; drops are ignored while it is open (D64), so close it first.
|
||||
await closeOpened(page);
|
||||
await expect(page.locator('#workspace-toast')).toHaveText(NO_TOAST);
|
||||
// The chooser names the dropped file, never the one it replaced and never "No file chosen".
|
||||
expect(await chooser(page).evaluate(node => [...node.files].map(file => file.name))).toEqual(['f02.png']);
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
@@ -275,7 +394,7 @@ test('drop accepts exactly one File and never navigates', async ({ page }) => {
|
||||
await expect(preview(page).locator('img')).toHaveAttribute('src', first);
|
||||
}
|
||||
await expect(action(page)).toHaveAttribute('aria-disabled', 'false');
|
||||
await openViewer(page);
|
||||
await reopenViewer(page);
|
||||
await expect(viewer(page).locator('#viewer-title')).toHaveText('f02.png');
|
||||
await expect(page).toHaveURL(/#\/inspect$/);
|
||||
});
|
||||
@@ -285,6 +404,7 @@ test('cancel and same-file reselect', async ({ page }) => {
|
||||
await openInspect(page);
|
||||
await choose(page, parityPng('f02.png'));
|
||||
await expect(preview(page).locator('img.local-file-preview')).toBeVisible();
|
||||
await closeOpened(page);
|
||||
const first = await preview(page).locator('img').getAttribute('src');
|
||||
// The chooser still names the chosen file, as in the concept; a cancel (no files) keeps the page as it is.
|
||||
expect(await chooser(page).evaluate(node => node.files[0]?.name)).toBe('f02.png');
|
||||
@@ -308,6 +428,7 @@ test('cancel and same-file reselect', async ({ page }) => {
|
||||
const state = await urls(page);
|
||||
expect(state.created).toHaveLength(2);
|
||||
expect(state.revoked).toEqual([first]);
|
||||
// The new selection opened the viewer again (D155).
|
||||
await openViewer(page);
|
||||
await expect(viewer(page).locator('.media-position')).toHaveText('1 of 1 loaded: f02.png');
|
||||
});
|
||||
@@ -337,6 +458,7 @@ test('preview does not imply metadata support', async ({ page }) => {
|
||||
// Text, markup, SVG and a playlist under media names are unsupported and never get a URL.
|
||||
const before = (await urls(page)).created.length;
|
||||
for (const file of disguised()) {
|
||||
// Unsupported: nothing opens (D155).
|
||||
await choose(page, file);
|
||||
await expect(status(page)).toHaveText(COPY.unsupported);
|
||||
await expect(status(page)).toHaveAttribute('role', 'alert');
|
||||
@@ -360,6 +482,7 @@ test('local video plays inline and in the viewer with real tags', async ({ page,
|
||||
await expect(rail(page).locator('.meta-grid').first().locator('dd').first()).toHaveText('7');
|
||||
await expect(viewer(page).locator('#viewer-stage video')).toHaveCount(1);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(viewer(page)).toHaveCount(0);
|
||||
await choose(page, tinyWebm());
|
||||
await expect(preview(page).locator('video.local-file-preview')).toBeVisible();
|
||||
await openViewer(page);
|
||||
@@ -386,6 +509,9 @@ test('the toast and a rejected drop never contradict the status line', async ({
|
||||
// concept's toast interval.
|
||||
await choose(page, undecodableMp4());
|
||||
const failed = status(page).filter({ hasText: COPY.previewFailed });
|
||||
// D155: the selection opened the viewer; its media failed there too, and the page's alert waits under it.
|
||||
await expect(failed).toHaveCount(1);
|
||||
await closeOpened(page);
|
||||
await expect(failed).toHaveCount(1);
|
||||
await drop(page, '.file-drop', { files: [parityPng('f02.png'), parityPng('f03.png')] });
|
||||
const rejected = status(page).filter({ hasText: COPY.multiple });
|
||||
@@ -442,7 +568,8 @@ test('URLs and media end with their owning session', async ({ page }) => {
|
||||
let state = await urls(page);
|
||||
expect(state.revoked).toEqual([state.created[0]]);
|
||||
expect(await page.evaluate(() => window.__loads)).toBeGreaterThanOrEqual(1);
|
||||
// Route change.
|
||||
// Route change, from the page (D155: the selection opened the viewer, which makes the sidebar inert).
|
||||
await closeOpened(page);
|
||||
await page.locator('aside.sidebar nav').getByRole('link', { name: 'Overview', exact: true }).click();
|
||||
await expect(main(page).getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
|
||||
state = await urls(page);
|
||||
|
||||
@@ -9,8 +9,9 @@ import {inventoryOf,mapConcept,diffInventory} from './library-editor-canonical-c
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const workspace='/prompt_manager/workspace';
|
||||
const ACTIONS={new:['Cancel','Save'],metadata:['Create text revision','Archive…','Cancel','Save'],revision:['Cancel','Save'],negative:['Cancel','Save']};
|
||||
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Rating','Notes'];
|
||||
const RATINGS=[['5','5 / 5'],['4','4 / 5'],['3','3 / 5'],['2','2 / 5'],['1','1 / 5'],['0','Unrated']];
|
||||
// D153 (#447): no Rating field; the fixture's positive stays stored with rating 4 through every workspace save.
|
||||
const LABELS=['Title','Positive prompt','Negative prompt (optional)','Category','Tags, separated by commas','Notes'];
|
||||
const UNAPPLIED='Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.';
|
||||
const editorDialog=page=>page.locator('dialog.library-editor');
|
||||
|
||||
test.beforeEach(async({page,request,browserName})=>{
|
||||
@@ -65,9 +66,8 @@ test('edit recipe metadata shows the concept fields with the selected saved nega
|
||||
await expect(dialog.getByRole('region')).toHaveCount(0);
|
||||
await expect(field(dialog,'Category')).toHaveValue('Portraits');
|
||||
await expect(field(dialog,'Tags, separated by commas')).toHaveValue('studio, window light');
|
||||
await expect(field(dialog,'Rating')).toHaveValue('4');
|
||||
await expect(field(dialog,'Notes')).toHaveValue(f.metadata.notes);
|
||||
expect(await field(dialog,'Rating').locator('option').evaluateAll(nodes=>nodes.map(node=>[node.value,node.textContent]))).toEqual(RATINGS);
|
||||
await expect(dialog.getByLabel('Rating')).toHaveCount(0);await expect(dialog.locator('select, #recipe-rating')).toHaveCount(0);
|
||||
expect(await field(dialog,'Category').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['Portraits','Sketches']);
|
||||
expect(await field(dialog,'Tags, separated by commas').evaluate(node=>[...node.list.options].map(option=>option.value))).toEqual(['draft,','studio,','window light,']);
|
||||
await expect(dialog.locator('[placeholder]')).toHaveCount(0);
|
||||
@@ -82,19 +82,20 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
const f=page.fixture;await mountEditor(page);
|
||||
let dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await field(dialog,'Title').fill('Edited portrait');await field(dialog,'Category').fill('');
|
||||
await field(dialog,'Tags, separated by commas').fill('studio, moody');await field(dialog,'Rating').selectOption('0');
|
||||
await field(dialog,'Tags, separated by commas').fill('studio, moody');
|
||||
await field(dialog,'Notes').fill('Edited notes');await button(dialog,'Save').click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual([`PATCH /prompt_manager/library/${f.positive_id}`]);
|
||||
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],rating:null,notes:'Edited notes'});
|
||||
expect(page.writes[0].body.metadata).toEqual({title:'Edited portrait',category:'',tags:['studio','moody'],notes:'Edited notes'});
|
||||
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
|
||||
const saved=(await detail(request,f.positive_id)).positive;
|
||||
expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:null,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
|
||||
// D153: the edit never sends a rating, so the stored 4 stays.
|
||||
expect(f.metadata.rating).toBe(4);expect(saved).toMatchObject({title:'Edited portrait',category:null,rating:4,notes:'Edited notes'});expect([...saved.tags].sort()).toEqual(['moody','studio']);
|
||||
|
||||
await page.reload();await mountEditor(page);
|
||||
dialog=await openEditor(page,{mode:'metadata',positiveId:f.positive_id,pairId:f.pair_id});await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue('Edited portrait');await expect(field(dialog,'Category')).toHaveValue('');
|
||||
await expect(field(dialog,'Rating')).toHaveValue('0');await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
||||
await expect(field(dialog,'Negative prompt (optional)')).toHaveValue(f.selected_negative);
|
||||
await button(dialog,'Create text revision').click();
|
||||
await expect(dialog.getByRole('heading',{name:'Create text revision',exact:true})).toBeVisible();
|
||||
await expect(editorDialog(page)).toHaveCount(1);expect(await dialog.evaluate(node=>node.open&&node.matches(':modal'))).toBe(true);
|
||||
@@ -106,12 +107,15 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
const create=page.writes.at(-1);expect(`${create.method} ${create.url}`).toBe('POST /prompt_manager/library');
|
||||
expect(create.body).toMatchObject({positive_text:'Editor portrait, revised dusk light',negative_text:f.selected_negative,
|
||||
parent_metadata:{title:'Edited portrait',category:'Uncategorized',rating:null,notes:'Edited notes'}});
|
||||
parent_metadata:{title:'Edited portrait',category:'Uncategorized',notes:'Edited notes'}});
|
||||
expect(Object.hasOwn(create.body.parent_metadata,'rating'),'D153: a revision sends no rating').toBe(false);
|
||||
expect([...create.body.parent_metadata.tags].sort()).toEqual(['moody','studio']);
|
||||
await expect.poll(()=>page.evaluate(()=>window.editorEvents.reconciled.length)).toBe(1);
|
||||
const revision=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
|
||||
expect(revision).toMatchObject({positive_created:true,pair_created:true});expect(revision.positive.id).not.toBe(f.positive_id);
|
||||
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized'});
|
||||
// D153: the revision is a new, unrated positive; the revised prompt keeps its stored rating.
|
||||
expect((await detail(request,revision.positive.id)).positive).toMatchObject({title:'Edited portrait',category:'Uncategorized',rating:null});
|
||||
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
|
||||
|
||||
dialog=await openEditor(page,{mode:'new'});await expect(dialog.getByRole('heading',{name:'New prompt',exact:true})).toBeVisible();await ready(dialog);
|
||||
expect(await actionNames(dialog)).toEqual(ACTIONS.new);
|
||||
@@ -120,9 +124,9 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
await expect(field(dialog,'Title')).toBeFocused();expect(await field(dialog,'Title').evaluate(node=>node.validity.valueMissing)).toBe(true);
|
||||
expect(page.writes).toHaveLength(before);
|
||||
await field(dialog,'Title').fill('Fresh idea');await field(dialog,'Positive prompt').fill('A fresh lighthouse at dawn');
|
||||
await field(dialog,'Rating').selectOption('5');await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
await button(dialog,'Save').click();await expect(dialog).toHaveCount(0);
|
||||
expect(page.writes.at(-1).body).toEqual({expected_epoch:expect.any(String),positive_text:'A fresh lighthouse at dawn',negative_text:'',
|
||||
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],rating:5,notes:''}});
|
||||
parent_metadata:{title:'Fresh idea',category:'Uncategorized',tags:[],notes:''}});
|
||||
|
||||
dialog=await openEditor(page,{mode:'negative',positiveId:f.positive_id});
|
||||
await expect(dialog.getByRole('heading',{name:'Add a negative variant',exact:true})).toBeVisible();await ready(dialog);
|
||||
@@ -134,6 +138,7 @@ test('editor modes persist through reload with selected saved negative',async({p
|
||||
const added=await page.evaluate(()=>window.editorEvents.committed.at(-1).receipt);
|
||||
expect(added).toMatchObject({positive_created:false,pair_created:true,pair:{negative_text:'grain, dust'}});
|
||||
await expect(page.locator('#editor-trigger')).toBeFocused();
|
||||
expect((await detail(request,f.positive_id)).positive.rating,'D153: adding a negative keeps the stored rating').toBe(4);
|
||||
});
|
||||
|
||||
test('archive confirms the selected pair with fresh counts and writes only on confirm',async({page,request})=>{
|
||||
@@ -804,20 +809,20 @@ test('archived reuse is reported, shown under Show archived and selected read-on
|
||||
await uniqueDotNames(page);
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(f.archived_negative);
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText(message);
|
||||
await expect(variantsPanel(page).locator('.library-variant-archived .variant-dot')).toHaveAttribute('aria-pressed','true');
|
||||
expect(page.writes.every(write=>write.method==='POST')).toBe(true);
|
||||
});
|
||||
|
||||
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page})=>{
|
||||
test('reuse that leaves parent metadata unapplied discloses it and keeps the draft for Edit metadata',async({page,request})=>{
|
||||
const f=page.fixture;await page.goto(`${workspace}#/library`);
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill('A different title');await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('brand new negative');
|
||||
await field(dialog,'Notes').fill('Draft notes');await button(dialog,'Save').click();
|
||||
await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
||||
await expect(toast(page)).toHaveText(UNAPPLIED);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText(f.metadata.title);
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue('A different title');await expect(field(dialog,'Notes')).toHaveValue('Draft notes');
|
||||
@@ -825,6 +830,8 @@ test('reuse that leaves parent metadata unapplied discloses it and keeps the dra
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
await expect(field(dialog,'Title')).toHaveValue(f.metadata.title);await button(dialog,'Cancel').click();
|
||||
expect(page.writes).toHaveLength(1);
|
||||
// D153: neither the reuse nor the kept draft touches the stored rating.
|
||||
expect((await detail(request,f.positive_id)).positive.rating).toBe(4);
|
||||
});
|
||||
|
||||
test('reuse with the stored parent metadata entered does not claim it was not applied',async({page})=>{
|
||||
@@ -832,7 +839,7 @@ test('reuse with the stored parent metadata entered does not claim it was not ap
|
||||
await page.getByRole('button',{name:'+ New prompt',exact:true}).click();let dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill('another brand new negative');
|
||||
await field(dialog,'Category').fill(f.metadata.category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
||||
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();dialog=editorDialog(page);await ready(dialog);
|
||||
@@ -848,13 +855,13 @@ test('reuse of an uncategorized prompt with the category left blank is not unapp
|
||||
await page.goto(`${workspace}#/library`);await page.getByRole('button',{name:'+ New prompt',exact:true}).click();const dialog=editorDialog(page);await ready(dialog);
|
||||
await field(dialog,'Title').fill(f.metadata.title);await field(dialog,'Positive prompt').fill(f.positive_text);await field(dialog,'Negative prompt (optional)').fill(negative);
|
||||
await field(dialog,'Category').fill(category);await field(dialog,'Tags, separated by commas').fill(f.metadata.tags.join(', '));
|
||||
await field(dialog,'Rating').selectOption(String(f.metadata.rating));await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await field(dialog,'Notes').fill(f.metadata.notes);
|
||||
await button(dialog,'Save').click();await expect(page).toHaveURL(new RegExp(`#/library/${f.positive_id}$`));
|
||||
};
|
||||
await reuse('','blank category negative');
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt.');
|
||||
await reuse('Landscapes','different category negative');
|
||||
await expect(toast(page)).toHaveText('Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.');
|
||||
await expect(toast(page)).toHaveText(UNAPPLIED);
|
||||
expect(page.writes.map(write=>`${write.method} ${write.url}`)).toEqual(['POST /prompt_manager/library','POST /prompt_manager/library']);
|
||||
});
|
||||
|
||||
|
||||
@@ -152,13 +152,25 @@ test('failed library reads retry while late search responses cannot overwrite ne
|
||||
await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape');
|
||||
});
|
||||
|
||||
// D153 (#447): a Browse visit that still names the retired "Highest rated" sort opens the shelf on Recently added.
|
||||
test('a Library visit with the retired rating sort opens on Recently added',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
const sorts=[];page.on('request',item=>{const url=new URL(item.url());if(url.pathname==='/prompt_manager/library')sorts.push(url.searchParams.get('sort'));});
|
||||
await page.goto('/prompt_manager/workspace#/overview');await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
await page.evaluate(async()=>{const {browseLibrary}=await import('/prompt_manager/js/workspace/library.mjs');browseLibrary({sort:'rating'});});
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(page.getByRole('combobox',{name:'Sort results',exact:true})).toHaveValue('newest');
|
||||
expect(sorts).toEqual(['newest']);
|
||||
});
|
||||
|
||||
test('library toolbar matches the concept and tags live in Refine this view',async({page})=>{
|
||||
await page.goto(route);await expect(cards(page)).toHaveCount(3);
|
||||
// app.js toolbar() a347–360: search.toolbar > label.search-field (input#search), select#category-filter, select#sort, Save view, Filters.
|
||||
const toolbar=page.locator('#workspace-main search.toolbar');await expect(toolbar).toHaveCount(1);
|
||||
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
|
||||
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Highest rated','Oldest first']);
|
||||
// D153 (#447): no "Highest rated".
|
||||
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
|
||||
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
|
||||
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
|
||||
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
|
||||
@@ -239,7 +251,8 @@ test('a sort change atomically discards old row geometry and renders changed par
|
||||
} finally {await page.evaluate(()=>window.releaseLibraryFrames());}
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape');
|
||||
await expect(changed.locator('.card-footer .ca-visually-hidden')).toHaveText('Rated 5 out of 5');await expect(changed.locator('.card-footer > span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
// D153: a stored rating of 5 still draws no stars.
|
||||
await expect(changed.locator('.card-footer > span')).not.toContainText('★');await expect(changed.locator('.card-footer > span > *')).toHaveCount(0);
|
||||
await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -445,8 +458,8 @@ test('Library cards clamp long prompts and keep real content inside the card',as
|
||||
for(const item of data.items) {
|
||||
const card=page.locator(`article[data-prompt-id="${item.id}"]`);
|
||||
const footer=card.locator('.card-footer > span');
|
||||
await expect(footer.locator('[aria-hidden="true"]')).toHaveText(item.rating?'★'.repeat(item.rating):[]);
|
||||
await expect(footer.locator('.ca-visually-hidden')).toHaveText(item.rating?`Rated ${item.rating} out of 5`:[]);
|
||||
// D153: no stars or "Rated N out of 5", whatever the stored rating.
|
||||
await expect(footer.locator('> *')).toHaveCount(0);
|
||||
await expect(card.locator('.card-content > .tags .chip')).toHaveText(item.tags);
|
||||
await expect(card.locator('.card-meta > span').nth(1)).toHaveText(`${item.run_count} run${item.run_count===1?'':'s'}`);
|
||||
}
|
||||
@@ -598,7 +611,7 @@ async function patchShelf(page,patch) {
|
||||
await route.fulfill({response,json:body});
|
||||
});
|
||||
}
|
||||
/** The footer line as seen: everything but its visually hidden rating sentence. */
|
||||
/** The footer line as seen: everything but visually hidden text (D153: there is no rating sentence any more). */
|
||||
const shownText=node=>[...node.childNodes].filter(child=>!child.classList?.contains('ca-visually-hidden')).map(child=>child.textContent).join('');
|
||||
const longPrompt=[`${'A luminous alpine observatory with layered glass terraces '.repeat(6)}`,'second paragraph keeps the full text',`final line ${'detail '.repeat(30)}end of prompt`].join('\n');
|
||||
|
||||
@@ -634,13 +647,13 @@ test('card matches the concept and never wraps the category mid-word',async({pag
|
||||
await expect(card.locator('.tags > .chip')).toHaveText(['cats','studio']);
|
||||
const footer=card.locator('.card-footer');
|
||||
expect(await footer.evaluate(node=>[...node.children].map(child=>`${child.tagName}.${child.className}`))).toEqual(['SPAN.','DIV.row-actions']);
|
||||
await expect(footer.locator('> span > [aria-hidden="true"]')).toHaveText('★★★★★');
|
||||
await expect(footer.locator('> span > .ca-visually-hidden')).toHaveText('Rated 5 out of 5');
|
||||
expect(await footer.locator('> span').evaluate(shownText)).toBe('★★★★★ · Used today');
|
||||
// D153 (#447): the stored rating 5 draws no stars; the footer reads only the last use.
|
||||
await expect(footer.locator('> span > *')).toHaveCount(0);
|
||||
expect(await footer.locator('> span').evaluate(shownText)).toBe('Used today');
|
||||
const actions=footer.locator('.row-actions > button');await expect(actions).toHaveCount(2);
|
||||
await expect(actions.first()).toHaveClass('send icon-btn');await expect(actions.first()).toHaveAccessibleName('Send positive prompt to ComfyUI: Cat portrait');
|
||||
await expect(actions.nth(1)).toHaveAccessibleName('Explore history');await expect(actions.nth(1).locator('svg')).toHaveCount(1);
|
||||
// Unrated and never used: the used label alone; no negatives still reads as a count.
|
||||
// Never used: the used label alone; no negatives still reads as a count.
|
||||
const unrated=page.locator(`article[data-prompt-id="${dual.id}"]`);
|
||||
expect(await unrated.locator('.card-footer > span').evaluate(shownText)).toBe('Not used yet');
|
||||
await expect(unrated.locator('.card-footer > span > [aria-hidden="true"]')).toHaveCount(0);
|
||||
|
||||
@@ -230,6 +230,31 @@ test('a view stored under older rules still lists and opens with its usable valu
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
|
||||
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
|
||||
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
|
||||
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [] } });
|
||||
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
|
||||
const listings = [];
|
||||
page.on('request', item => {
|
||||
const url = new URL(item.url());
|
||||
if (['/prompt_manager/images/output', '/prompt_manager/library'].includes(url.pathname)) listings.push(url.searchParams.get('sort'));
|
||||
});
|
||||
await page.goto(`${workspace}#/saved/${images.id}`);
|
||||
await expect(heading(page)).toHaveText('Rated cats');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
expect(await sortBy(page).locator('option').allTextContents()).not.toContain('Highest rated');
|
||||
await expect(search(page)).toHaveValue('cat');
|
||||
await expect(imageCards(page)).toHaveCount(1);
|
||||
await savedNav(page).getByRole('link', { name: 'Rated dogs', exact: true }).click();
|
||||
await expect(heading(page)).toHaveText('Rated dogs');
|
||||
await expect(sortBy(page)).toHaveValue('newest');
|
||||
await expect(search(page)).toHaveValue('dog');
|
||||
await expect(promptCards(page)).toHaveCount(1);
|
||||
expect(listings.length).toBeGreaterThan(1);
|
||||
expect(listings).not.toContain('rating');
|
||||
});
|
||||
|
||||
test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => {
|
||||
const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } });
|
||||
const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } });
|
||||
|
||||
@@ -79,7 +79,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
|
||||
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
|
||||
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
|
||||
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
|
||||
['Statistics', 'Runs and ratings by category, with library totals.', 'Explore'],
|
||||
['Statistics', 'Runs by category, with library totals.', 'Explore'],
|
||||
]);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
@@ -373,13 +373,12 @@ test('statistics dialog lists runs by category and library totals', async ({ pag
|
||||
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
|
||||
const tables = dialog.locator('#modal-body > table');
|
||||
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
|
||||
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs', 'Avg. rating']);
|
||||
// D153 (#447): no rating column and no rated-images total.
|
||||
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
|
||||
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
|
||||
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized',
|
||||
number(row.runs), row.average_rating === null ? 'Not rated' : `${number(row.average_rating)} / 5`]));
|
||||
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
|
||||
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
|
||||
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)],
|
||||
['Rated images', number(statistics.rated_images)]]);
|
||||
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(explore).toBeFocused();
|
||||
// A statistics read that fails says so; the Overview stays.
|
||||
|
||||
@@ -36,11 +36,11 @@ async function watchHost(page,id,reset=true) {
|
||||
}
|
||||
const restore=page=>page.evaluate(async()=>{const {app}=await import('/scripts/app.js');Object.assign(app,window.inspectOriginals);});
|
||||
|
||||
/** Chooses `file` on the Inspect page and opens the shared viewer on its local record. */
|
||||
/** Chooses `file` on the Inspect page, which opens the shared viewer on its local record (D155, #448). */
|
||||
async function inspect(workspace,{file,positive,shown}) {
|
||||
await workspace.locator('#local-file').setInputFiles(file());
|
||||
const open=workspace.locator('#workspace-main .heading-actions').getByRole('button',{name:'Open file metadata',exact:true});
|
||||
await expect(open).toHaveAttribute('aria-disabled','false');await open.click();
|
||||
await expect(open).toHaveAttribute('aria-disabled','false');
|
||||
const rail=workspace.locator('dialog#viewer-dialog[open] #viewer-details');
|
||||
await expect(rail.locator('.prompt-section.positive p.prompt-text')).toHaveText(positive);
|
||||
await expect(rail.locator('.prompt-section.negative p.prompt-text')).toHaveText(shown);
|
||||
|
||||
@@ -211,11 +211,12 @@ test('custom collection pages reuse image-stream cancellation, cursors and ident
|
||||
assert.throws(()=>createImageStream({api:{get:async()=>{}},fetchPage:'invalid'}),/page/i);
|
||||
});
|
||||
|
||||
test('stream sends category, tags and the rating sort and validates them', async () => {
|
||||
test('stream sends category, tags and the sort and validates them; the rating sort is gone (D153)', async () => {
|
||||
const {createImageStream}=await source;const calls=[];
|
||||
const stream=createImageStream({api:{get:async url=>{calls.push(new URL(url,'http://local').searchParams);return page([]);}}});
|
||||
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'rating'});
|
||||
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'rating');
|
||||
await stream.reset({q:'cat',category:'Animals',tags:['cats','studio'],sort:'name-desc'});
|
||||
assert.equal(calls[0].get('category'),'Animals');assert.equal(calls[0].get('tags'),'cats,studio');assert.equal(calls[0].get('sort'),'name-desc');
|
||||
await assert.rejects(stream.reset({sort:'rating'}),/sort/i);
|
||||
await stream.reset({category:'',tags:[]});
|
||||
assert.equal(calls[1].has('category'),false);assert.equal(calls[1].has('tags'),false);
|
||||
for(const filters of [{category:3},{tags:'cats'},{tags:[1]},{tags:['a,b']},{tags:['']}]) await assert.rejects(stream.reset(filters),/filter/i);
|
||||
@@ -277,7 +278,7 @@ test('the caption date names the newest result day under Newest first only', asy
|
||||
assert.equal(captionDate([at(2026,9,10),at(2026,9,16,23)],'oldest',now),'');
|
||||
assert.equal(captionDate([at(2026,9,13)],'newest',now),'Tuesday · October 13');
|
||||
assert.equal(captionDate([at(2025,2,3)],'newest',now),'Monday · March 3, 2025');
|
||||
for(const sort of ['name','name-desc','rating']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
|
||||
for(const sort of ['name','name-desc']) assert.equal(captionDate([at(2026,9,17)],sort,now),'');
|
||||
assert.equal(captionDate([],'newest',now),'');
|
||||
assert.equal(captionDate([{modified_time:'bad'}],'newest',now),'');
|
||||
});
|
||||
|
||||
@@ -117,8 +117,9 @@ test('new prompt from Images waits for a fresh options epoch', async () => {
|
||||
assert.equal(sentBody.expected_epoch, EPOCH);
|
||||
assert.deepEqual(sentBody, {
|
||||
expected_epoch: EPOCH, positive_text: 'a portrait', negative_text: '',
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], rating: null, notes: '' },
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: [], notes: '' },
|
||||
});
|
||||
assert.equal(Object.hasOwn(sentBody.parent_metadata, 'rating'), false, 'D153: a new prompt sends no rating');
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(committed.length, 1);
|
||||
assert.equal(committed[0].kind, 'create');
|
||||
@@ -230,11 +231,11 @@ test('editor recovery preserves hyphenated ApiError codes', async () => {
|
||||
const server = harness(library({ write: () => response(409, { success: false, code, error: 'Conflict', library_epoch: EPOCH, ...extra }) }));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', 5);
|
||||
controller.setField('notes', 'kept');
|
||||
await controller.submit();
|
||||
phases.push(controller.state.phase);
|
||||
if (controller.state.phase !== 'write-error') errors.push(controller.state.error);
|
||||
assert.equal(controller.state.draft.rating, 5, 'draft preserved');
|
||||
assert.equal(controller.state.draft.notes, 'kept', 'draft preserved');
|
||||
assert.equal(controller.state.receipt, null);
|
||||
controller.dispose();
|
||||
}
|
||||
@@ -306,7 +307,7 @@ test('confirmed save refresh retry is read only', async () => {
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
let failReads = 1;
|
||||
const committed = [], reconciled = [];
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext', edit_token: TOKEN2 }) };
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext', edit_token: TOKEN2 }) };
|
||||
const base = library({ write: () => response(200, receipt) });
|
||||
const server = harness(call => {
|
||||
if (call.method === 'GET' && server.writes().length && failReads-- > 0) return Promise.reject(new TypeError('offline'));
|
||||
@@ -315,7 +316,6 @@ test('confirmed save refresh retry is read only', async () => {
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
||||
onCommitted: commit => committed.push(commit), onReconciled: result => reconciled.push(result) });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', null);
|
||||
controller.setField('notes', 'keep\ntext');
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'saved-refresh-error');
|
||||
@@ -338,7 +338,7 @@ test('confirmed save refresh retry is read only', async () => {
|
||||
test('legacy title fallback is not an edit', async () => {
|
||||
const { createLibraryEditorController } = await source;
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ rating: null, notes: 'keep\ntext' }) };
|
||||
const receipt = { success: true, library_epoch: EPOCH, positive: positive({ notes: 'keep\ntext' }) };
|
||||
const server = harness(library({ write: () => response(200, receipt) }));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
@@ -346,7 +346,6 @@ test('legacy title fallback is not an edit', async () => {
|
||||
assert.equal(controller.state.baseline.title, null, 'stored title stays distinct');
|
||||
assert.equal(controller.state.draft.tags, 'soft, warm');
|
||||
assert.equal(controller.state.draft.negative_text, 'blurry', 'the selected pair negative');
|
||||
controller.setField('rating', null);
|
||||
controller.setField('notes', 'keep\ntext');
|
||||
controller.setField('tags', 'warm, soft,');
|
||||
controller.setField('category', ' Studio ');
|
||||
@@ -357,7 +356,7 @@ test('legacy title fallback is not an edit', async () => {
|
||||
const sentBody = write.body;
|
||||
assert.equal(sentBody.expected_epoch, EPOCH);
|
||||
assert.equal(sentBody.if_match, TOKEN);
|
||||
assert.deepEqual(sentBody.metadata, { rating: null, notes: 'keep\ntext' });
|
||||
assert.deepEqual(sentBody.metadata, { notes: 'keep\ntext' });
|
||||
|
||||
const unchanged = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await unchanged.initialize();
|
||||
@@ -421,12 +420,12 @@ test('metadata conflict reload keeps changed fields for review and never replays
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
let owner = positive();
|
||||
const server = harness(call => library({ owner, write: write => write.body.if_match === TOKEN2
|
||||
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, rating: 5 } })
|
||||
? response(200, { success: true, library_epoch: EPOCH, positive: { ...owner, notes: 'mine' } })
|
||||
: response(409, { success: false, code: 'metadata-conflict', error: 'changed', library_epoch: EPOCH, positive: owner }) })(call));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', '5');
|
||||
owner = positive({ title: 'Renamed elsewhere', notes: 'other writer', edit_token: TOKEN2 });
|
||||
controller.setField('notes', 'mine');
|
||||
owner = positive({ title: 'Renamed elsewhere', category: 'Elsewhere', edit_token: TOKEN2 });
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'conflict');
|
||||
await controller.submit();
|
||||
@@ -434,20 +433,20 @@ test('metadata conflict reload keeps changed fields for review and never replays
|
||||
await controller.reloadOwner({ keepDraft: true });
|
||||
assert.equal(controller.state.phase, 'ready');
|
||||
assert.equal(controller.state.reviewRequired, true);
|
||||
assert.equal(controller.state.draft.rating, 5, 'changed field kept');
|
||||
assert.equal(controller.state.draft.notes, 'mine', 'changed field kept');
|
||||
assert.equal(controller.state.draft.title, 'Renamed elsewhere', 'untouched field follows the current owner');
|
||||
assert.equal(controller.state.draft.notes, 'other writer');
|
||||
assert.equal(controller.state.draft.category, 'Elsewhere');
|
||||
assert.equal(server.writes().length, 1);
|
||||
await controller.submit();
|
||||
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { rating: 5 } });
|
||||
assert.deepEqual(server.writes()[1].body, { expected_epoch: EPOCH, if_match: TOKEN2, metadata: { notes: 'mine' } });
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
controller.dispose();
|
||||
|
||||
const discard = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await discard.initialize();
|
||||
discard.setField('rating', 1);
|
||||
discard.setField('notes', 'dropped');
|
||||
await discard.reloadOwner({ keepDraft: false });
|
||||
assert.equal(discard.state.draft.rating, 3);
|
||||
assert.equal(discard.state.draft.notes, '');
|
||||
assert.equal(discard.state.reviewRequired, false);
|
||||
discard.dispose();
|
||||
});
|
||||
@@ -457,12 +456,12 @@ test('metadata unknown outcome retry reloads the owner instead of replaying', as
|
||||
assert.equal(typeof createLibraryEditorController, 'function', 'controller export');
|
||||
let owner = positive();
|
||||
const server = harness(call => library({ owner, write: () => {
|
||||
owner = positive({ rating: 5, edit_token: TOKEN2 });
|
||||
owner = positive({ notes: 'applied', edit_token: TOKEN2 });
|
||||
return Promise.reject(new TypeError('Failed to fetch'));
|
||||
} })(call));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31 });
|
||||
await controller.initialize();
|
||||
controller.setField('rating', 5);
|
||||
controller.setField('notes', 'applied');
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'unknown-outcome');
|
||||
await controller.retryUnconfirmed();
|
||||
@@ -570,8 +569,10 @@ test('revision seeds text from the selected pair and sends Uncategorized for a b
|
||||
await controller.submit();
|
||||
assert.deepEqual(server.writes()[0].body, {
|
||||
expected_epoch: EPOCH, positive_text: 'First line\nrevised', negative_text: 'blurry',
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], rating: 3, notes: 'n' },
|
||||
parent_metadata: { title: 'Portrait', category: 'Uncategorized', tags: ['a', 'b'], notes: 'n' },
|
||||
});
|
||||
// D153: the revision's new positive is unrated; the owner's stored rating (3) is neither copied nor written.
|
||||
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
@@ -589,7 +590,9 @@ test('invalid drafts and server failures keep the draft without a receipt', asyn
|
||||
assert.equal(controller.state.phase, 'ready');
|
||||
assert.match(controller.state.error.message, /Title/);
|
||||
assert.throws(() => controller.setField('unknown', 'x'), TypeError);
|
||||
assert.throws(() => controller.setField('rating', 7), TypeError);
|
||||
// D153: the editor has no rating field.
|
||||
assert.throws(() => controller.setField('rating', 4), /Unknown editor field: rating/);
|
||||
assert.throws(() => controller.setField('notes', 4), TypeError);
|
||||
controller.setField('title', 'T');
|
||||
await controller.submit();
|
||||
assert.equal(controller.state.phase, 'write-error');
|
||||
@@ -807,3 +810,44 @@ test('a blank category reused against an uncategorized owner is not unapplied me
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// D153 (#447): the workspace no longer edits the 1–5 rating, and a save never clears a stored one.
|
||||
test('editing a prompt rated 4 sends no rating and the saved prompt keeps 4', async () => {
|
||||
const { createLibraryEditorController, EDITOR_FIELDS } = await source;
|
||||
assert.deepEqual(EDITOR_FIELDS, ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
|
||||
let owner = positive({ rating: 4, title: 'Portrait' });
|
||||
const server = harness(call => library({ owner, write: write => {
|
||||
owner = positive({ ...owner, ...write.body.metadata, edit_token: TOKEN2 });
|
||||
return response(200, { success: true, library_epoch: EPOCH, positive: owner });
|
||||
} })(call));
|
||||
const reconciled = [];
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'metadata', positiveId: 7, pairId: 31,
|
||||
onReconciled: result => reconciled.push(result) });
|
||||
await controller.initialize();
|
||||
assert.equal(Object.hasOwn(controller.state.draft, 'rating'), false, 'the draft has no rating');
|
||||
assert.equal(Object.hasOwn(controller.state.baseline, 'rating'), false, 'the baseline has no rating');
|
||||
controller.setField('title', 'Portrait, edited');
|
||||
await controller.submit();
|
||||
const [write] = server.writes();
|
||||
assert.deepEqual(write.body.metadata, { title: 'Portrait, edited' });
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(controller.state.receipt.positive.rating, 4);
|
||||
assert.equal(reconciled[0].detail.positive.rating, 4, 'the re-read prompt still has its rating');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('a reused rated positive is not reported as unapplied metadata for its rating', async () => {
|
||||
const { createLibraryEditorController } = await source;
|
||||
const reused = recipe({ positive_created: false, pair_created: true, parent_metadata_applied: false,
|
||||
positive: positive({ id: 9, text: 'a portrait', title: 'Portrait', category: 'Uncategorized', tags: [], rating: 4, notes: '' }) });
|
||||
const server = harness(created(() => response(200, reused)));
|
||||
const controller = createLibraryEditorController({ api: server.api, mode: 'new' });
|
||||
await controller.initialize();
|
||||
controller.setField('title', 'Portrait');
|
||||
controller.setField('positive_text', 'a portrait');
|
||||
await controller.submit();
|
||||
assert.equal(Object.hasOwn(server.writes()[0].body.parent_metadata, 'rating'), false);
|
||||
assert.equal(controller.state.phase, 'complete');
|
||||
assert.equal(controller.state.outcome.metadataNotApplied, false);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
@@ -46,9 +46,8 @@ test('used label boundaries in UTC and local time', async () => {
|
||||
for (const missing of [null, undefined, '', 'not a date', 42]) assert.equal(usedLabel(missing, new Date()), 'Not used yet');
|
||||
});
|
||||
|
||||
test('rating label omits unrated', async () => {
|
||||
const { ratingLabel } = await source;
|
||||
assert.deepEqual(ratingLabel(5), { stars: '★★★★★', hidden: 'Rated 5 out of 5' });
|
||||
assert.deepEqual(ratingLabel(1), { stars: '★', hidden: 'Rated 1 out of 5' });
|
||||
for (const unrated of [null, undefined, 0, 6, -1, 2.5, '3', true]) assert.deepEqual(ratingLabel(unrated), { stars: '', hidden: '' });
|
||||
// D153 (#447): cards show no rating, so the layout module has no rating label.
|
||||
test('the layout module offers no rating label', async () => {
|
||||
const layout = await source;
|
||||
assert.equal(Object.hasOwn(layout, 'ratingLabel'), false);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ const item=id=>({id,text:`Positive ${id}`,category:'Art',tags:['blue'],rating:3,
|
||||
const page=(items,cursor=null)=>({items,total:3,variant_total:6,has_more:cursor!==null,next_cursor:cursor});
|
||||
test('parent stream builds literal filters and deduplicates stable identities across infinite pages',async()=>{
|
||||
const {createLibraryStream}=await source;const calls=[];const pending=[page([item(1),item(2)],'next'),page([item(2),item(3)])];const s=createLibraryStream({api:{get:async(url)=>{calls.push(url);return pending.shift();}}});
|
||||
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'rating'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
|
||||
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'rating');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
|
||||
assert.equal(await s.reset({text:'a%_',category:'Art',tags:['blue','red'],sort:'name'}),2);assert.equal(await s.loadMore(),1);assert.deepEqual(s.state.items.map(row=>row.id),[1,2,3]);assert.equal(s.state.hasMore,false);
|
||||
const query=new URL(calls[0],'https://x').searchParams;assert.equal(query.get('text'),'a%_');assert.deepEqual(query.getAll('tag'),['blue','red']);assert.equal(query.get('sort'),'name');assert.equal(new URL(calls[1],'https://x').searchParams.get('cursor'),'next');assert.equal(await s.loadMore(),0);s.dispose();
|
||||
});
|
||||
test('the shelf caption total of negative variants comes from variant_total and resets with the filters',async()=>{
|
||||
const {createLibraryStream}=await source;const pending=[page([item(1)],'next'),{...page([item(2)]),variant_total:7},{...page([item(3)]),variant_total:1}];
|
||||
@@ -114,3 +114,9 @@ test('the stream keeps the first page\'s storage epoch and refuses a later page
|
||||
const fresh=[{...page([item(4)]),library_epoch:B}];const reset=s.reset({text:'x'});assert.equal(s.state.epoch,null);
|
||||
s.dispose();const next=createLibraryStream({api:{get:async()=>fresh.shift()}});await next.reset();assert.equal(next.state.epoch,B);next.dispose();await reset;
|
||||
});
|
||||
test('the shelf no longer sorts by rating (D153)',async()=>{
|
||||
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
|
||||
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
|
||||
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
|
||||
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
|
||||
});
|
||||
|
||||
@@ -72,9 +72,9 @@ test('the listing keeps usable views in order and normalizes their state', async
|
||||
|
||||
test('page filters and saved states convert both ways', async () => {
|
||||
const { imagesFilters, imagesViewState, libraryFilters, libraryViewState } = await source;
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
|
||||
const filters = { q: 'castle', category: 'Portraits', sort: 'name-desc', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' };
|
||||
const state = imagesViewState(filters);
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
|
||||
assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true, collection: '' });
|
||||
assert.deepEqual(imagesFilters(state), filters);
|
||||
assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false, collection: '' });
|
||||
// #331 D140: the open collection round-trips; a missing one is ''.
|
||||
@@ -215,3 +215,13 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5)
|
||||
await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError);
|
||||
assert.deepEqual(api.calls, []);
|
||||
});
|
||||
|
||||
test('a view saved under the retired Highest rated sort opens on Recently added (D153)', async () => {
|
||||
const { normalizeViewState, imagesFilters, libraryFilters } = await source;
|
||||
assert.equal(normalizeViewState('images', { sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(normalizeViewState('library', { sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
|
||||
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
|
||||
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
|
||||
});
|
||||
|
||||
@@ -141,7 +141,7 @@ test('the report summary says when it was checked and whether it is complete', a
|
||||
assert.equal(healthSummary({ ...REPORT, checked_at: 'x' }), 'Nothing was changed; review the checks with issues.');
|
||||
});
|
||||
|
||||
test('statistics rows are the real runs and ratings, then the library totals', async () => {
|
||||
test('statistics rows are the real runs, then the library totals, without ratings (D153)', async () => {
|
||||
const { categoryRows, totalsRows } = await overviewModule;
|
||||
const statistics = {
|
||||
by_category: [{ category: 'Landscapes', runs: 1200, average_rating: 4.5 },
|
||||
@@ -149,8 +149,8 @@ test('statistics rows are the real runs and ratings, then the library totals', a
|
||||
totals: { total_prompts: 12, total_tags: 15, total_images: 24 },
|
||||
rated_images: 20,
|
||||
};
|
||||
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString(), '4.5 / 5'], ['Uncategorized', '3', 'Not rated']]);
|
||||
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24'], ['Rated images', '20']]);
|
||||
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
|
||||
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
|
||||
});
|
||||
|
||||
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
"""tools/repair_foreign_links.py: the reviewed repair of the #247 dataset links.
|
||||
|
||||
The fixture is a library whose foreign (no PromptManager node) dataset run
|
||||
wrote shared-prefix files that the old scan linked to the wrong branch text.
|
||||
Recorded history names each file's saver, so the ladder (``recorded-run``)
|
||||
names the right branch.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
from tests.test_run_attribution import RecordedRuns
|
||||
from tools import repair_foreign_links as tool
|
||||
from utils.file_prompt import Decision
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
TEXTS = shapes.BRANCH_TEXTS
|
||||
NAME = "IMG_GEN_{:05}_.png"
|
||||
|
||||
|
||||
def png(path, graph):
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
Image.new("RGB", (4, 4)).save(path, pnginfo=info)
|
||||
|
||||
|
||||
def sha(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def file_sha(path):
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def dump(path):
|
||||
"""The database's logical content (independent of WAL checkpoints)."""
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
return list(conn.iterdump())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def not_running(_comfyui_dir, _port):
|
||||
return []
|
||||
|
||||
|
||||
def no_handles(_database):
|
||||
return []
|
||||
|
||||
|
||||
class RepairFixture(RecordedRuns, unittest.TestCase):
|
||||
"""Rows: A relink to a new prompt, B relink to a stored prompt (with a
|
||||
pair link), C keep, D file gone, E two savers (skip), F the UNIQUE case
|
||||
(a wrong row beside a correct row), H relink from a rated prompt, G a
|
||||
PromptManager run (not a candidate)."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.addCleanup(assert_closed, self, self.tmp)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.output = os.path.join(self.tmp, "output")
|
||||
os.makedirs(os.path.join(self.output, "dataset"))
|
||||
self.db_path = os.path.join(self.tmp, "prompts.db")
|
||||
self.plan_path = os.path.join(self.tmp, "plan.json")
|
||||
self.backups = os.path.join(self.tmp, "backups")
|
||||
os.makedirs(self.backups)
|
||||
self.comfy = os.path.join(self.tmp, "comfy")
|
||||
os.makedirs(self.comfy)
|
||||
Path(self.comfy, "main.py").write_text("# ComfyUI\n", encoding="utf-8")
|
||||
self.files = {}
|
||||
for key, number in zip("ABCDEFHG", range(1, 9)):
|
||||
path = os.path.join(self.output, "dataset", NAME.format(number))
|
||||
png(path, shapes.shared_prefix())
|
||||
self.files[key] = path
|
||||
db = PromptDatabase(self.db_path)
|
||||
try:
|
||||
self.build(db)
|
||||
finally:
|
||||
db.close_all()
|
||||
os.remove(self.files["D"])
|
||||
|
||||
def build(self, db):
|
||||
save = lambda text, **kw: db.save_prompt( # noqa: E731
|
||||
text=text, prompt_hash=generate_prompt_hash(text), **kw
|
||||
)
|
||||
self.p0 = save(TEXTS[0], category="scanned", tags=["auto-scanned"])
|
||||
self.p2 = save(TEXTS[2], category="scanned", tags=["auto-scanned"])
|
||||
self.orphan = save(
|
||||
"an old guess",
|
||||
category="scanned",
|
||||
tags=["auto-scanned"],
|
||||
notes="Auto-scanned from IMG_GEN_00001_.png",
|
||||
)
|
||||
self.rated = save("a rated guess", rating=5)
|
||||
link = lambda prompt, key: db.link_image_to_prompt( # noqa: E731
|
||||
prompt, self.files[key], metadata={}
|
||||
)
|
||||
self.rows = {
|
||||
"A": link(self.orphan, "A"),
|
||||
"B": link(self.p0, "B"),
|
||||
"C": link(self.p0, "C"),
|
||||
"D": link(self.p0, "D"),
|
||||
"E": link(self.p0, "E"),
|
||||
"F": link(self.p0, "F"),
|
||||
"F_ok": link(self.p2, "F"),
|
||||
"H": link(self.rated, "H"),
|
||||
"G": link(self.p0, "G"),
|
||||
}
|
||||
conn = db.model.get_connection()
|
||||
with conn:
|
||||
pair = conn.execute(
|
||||
"INSERT INTO prompt_pairs(positive_prompt_id) VALUES(?)", (self.p0,)
|
||||
).lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO image_prompt_pairs(image_id, pair_id, provenance) "
|
||||
"VALUES(?,?,'{}')",
|
||||
(self.rows["B"], pair),
|
||||
)
|
||||
name = lambda key: ("dataset", os.path.basename(self.files[key])) # noqa
|
||||
# Savers of the shared-prefix graph: "13", "23", "33", "43" (branches 1-4)
|
||||
self.record_run(
|
||||
db,
|
||||
self.output,
|
||||
[name(k) for k in "CDEAEBFH"],
|
||||
[],
|
||||
nodes=["13", "13", "13", "23", "23", "33", "33", "43"],
|
||||
)
|
||||
self.record_run(db, self.output, [name("G")], [TEXTS[0]], item=1, nodes=["23"])
|
||||
|
||||
def dry_run(self, *args):
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--output-dir",
|
||||
self.output,
|
||||
"--plan",
|
||||
self.plan_path,
|
||||
*args,
|
||||
],
|
||||
out=out,
|
||||
)
|
||||
return code, out.getvalue()
|
||||
|
||||
def apply(self, *args, probe=not_running, handles=no_handles):
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--apply",
|
||||
self.plan_path,
|
||||
"--comfyui-dir",
|
||||
os.path.join(self.tmp, "comfy"),
|
||||
"--backup-dir",
|
||||
self.backups,
|
||||
*args,
|
||||
],
|
||||
out=out,
|
||||
probe=probe,
|
||||
handles=handles,
|
||||
)
|
||||
return code, out.getvalue()
|
||||
|
||||
def plan(self):
|
||||
with open(self.plan_path, encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
def write_plan(self, plan):
|
||||
with open(self.plan_path, "w", encoding="utf-8") as stream:
|
||||
json.dump(plan, stream)
|
||||
|
||||
def query(self, sql, args=()):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
return conn.execute(sql, args).fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def query_write(self, sql, args):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
with conn:
|
||||
conn.execute(sql, args)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def links(self):
|
||||
return dict(self.query("SELECT id, prompt_id FROM generated_images"))
|
||||
|
||||
|
||||
class DryRunTests(RepairFixture):
|
||||
def test_plan_names_relink_keep_and_skip_rows(self):
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
items = {item["row_id"]: item for item in self.plan()["rows"]}
|
||||
rows = self.rows
|
||||
self.assertNotIn(rows["G"], items, "a PromptManager run is not foreign")
|
||||
actions = {key: items[rows[key]]["action"] for key in rows if key != "G"}
|
||||
self.assertEqual(
|
||||
actions,
|
||||
{
|
||||
"A": "relink",
|
||||
"B": "relink",
|
||||
"C": "keep",
|
||||
"D": "skip",
|
||||
"E": "skip",
|
||||
"F": "relink",
|
||||
"F_ok": "keep",
|
||||
"H": "relink",
|
||||
},
|
||||
)
|
||||
a = items[rows["A"]]
|
||||
self.assertEqual(a["current_prompt_id"], self.orphan)
|
||||
self.assertEqual(a["current_text_sha256"], sha("an old guess"))
|
||||
self.assertEqual(a["target_text"], TEXTS[1])
|
||||
self.assertEqual(a["target_hash"], generate_prompt_hash(TEXTS[1]))
|
||||
self.assertEqual(a["target_prompt_id"], "new")
|
||||
self.assertEqual(a["file"], self.files["A"])
|
||||
self.assertEqual(items[rows["B"]]["target_prompt_id"], self.p2)
|
||||
self.assertEqual(items[rows["H"]]["target_prompt_id"], "new")
|
||||
self.assertEqual(items[rows["D"]]["reason"], "file gone")
|
||||
self.assertEqual(
|
||||
items[rows["E"]]["reason"], "not decided by the recorded saver"
|
||||
)
|
||||
self.assertIn("relink: 4", out)
|
||||
self.assertIn("keep: 2", out)
|
||||
self.assertIn("skip: 2", out)
|
||||
|
||||
def test_orphan_audit_lists_the_prompts_losing_every_link(self):
|
||||
self.dry_run()
|
||||
plan = self.plan()
|
||||
orphans = {o["id"]: o for o in plan["orphans"]}
|
||||
self.assertEqual(set(orphans), {self.orphan, self.rated})
|
||||
self.assertEqual(orphans[self.orphan]["blockers"], [])
|
||||
self.assertTrue(orphans[self.orphan]["safe"])
|
||||
self.assertEqual(orphans[self.rated]["blockers"], ["usage or rating"])
|
||||
self.assertFalse(orphans[self.rated]["safe"])
|
||||
self.assertFalse(plan["delete_orphans"], "off by default")
|
||||
self.dry_run("--delete-orphans")
|
||||
self.assertTrue(self.plan()["delete_orphans"])
|
||||
|
||||
def test_dry_run_writes_nothing_and_writes_the_markdown_list(self):
|
||||
before = file_sha(self.db_path)
|
||||
listing = sorted(os.listdir(self.tmp))
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(file_sha(self.db_path), before)
|
||||
self.assertIn("Dry run: nothing was changed.", out)
|
||||
# A read-only reader of a WAL library may leave its empty side files
|
||||
sidecars = {"prompts.db-shm", "prompts.db-wal"}
|
||||
self.assertEqual(
|
||||
sorted(set(os.listdir(self.tmp)) - sidecars),
|
||||
sorted(set(listing + ["plan.json", "plan.list.md"]) - sidecars),
|
||||
)
|
||||
wal = Path(self.tmp, "prompts.db-wal")
|
||||
self.assertEqual(wal.stat().st_size if wal.exists() else 0, 0)
|
||||
markdown = Path(self.tmp, "plan.list.md").read_text(encoding="utf-8")
|
||||
self.assertIn(f"| {self.rows['A']} |", markdown)
|
||||
long_path = os.path.join("dataset", NAME.format(1))
|
||||
self.assertIn(f"| {long_path} |", markdown, "file paths are never cut")
|
||||
self.assertIn(generate_prompt_hash(TEXTS[1])[:12], markdown)
|
||||
self.assertIn("new", markdown)
|
||||
self.assertIn("would lose every link", markdown)
|
||||
|
||||
|
||||
class DryRunReviewTests(RepairFixture):
|
||||
def test_plan_records_its_library_and_fingerprint(self):
|
||||
self.dry_run()
|
||||
plan = self.plan()
|
||||
self.assertEqual(
|
||||
plan["source_db"], os.path.normcase(os.path.realpath(self.db_path))
|
||||
)
|
||||
self.assertEqual(plan["max_row_id"], max(self.links()))
|
||||
|
||||
def test_a_markdown_plan_path_is_refused(self):
|
||||
out = io.StringIO()
|
||||
with (
|
||||
self.assertRaises(SystemExit),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--output-dir",
|
||||
self.output,
|
||||
"--plan",
|
||||
os.path.join(self.tmp, "plan.md"),
|
||||
],
|
||||
out=out,
|
||||
)
|
||||
self.assertFalse(os.path.exists(os.path.join(self.tmp, "plan.md")))
|
||||
|
||||
def test_keep_compares_the_linked_text_not_the_stored_hash(self):
|
||||
self.query_write("UPDATE prompts SET hash='stale' WHERE id=?", (self.p0,))
|
||||
self.dry_run()
|
||||
items = {item["row_id"]: item for item in self.plan()["rows"]}
|
||||
self.assertEqual(items[self.rows["C"]]["action"], "keep")
|
||||
|
||||
def test_delete_orphans_is_rehearsed_with_the_deletes(self):
|
||||
code, out = self.dry_run("--delete-orphans")
|
||||
self.assertEqual(code, 0, out)
|
||||
simulated = self.plan()["simulated"]
|
||||
self.assertEqual(simulated["result"]["orphans_deleted"], 1)
|
||||
self.assertEqual(
|
||||
simulated["after"]["prompts"], simulated["before"]["prompts"] + 2 - 1
|
||||
)
|
||||
|
||||
def test_a_file_on_another_drive_is_listed_by_its_full_path(self):
|
||||
self.dry_run()
|
||||
plan = self.plan()
|
||||
with unittest.mock.patch.object(
|
||||
tool.os.path, "relpath", side_effect=ValueError("other drive")
|
||||
):
|
||||
listing = tool.markdown(plan, {})
|
||||
self.assertIn(self.files["A"], listing)
|
||||
|
||||
|
||||
class ApplyTests(RepairFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def test_apply_relinks_creates_targets_and_drops_pair_links(self):
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
links = self.links()
|
||||
new = dict(self.query("SELECT hash, id FROM prompts"))
|
||||
target_a = new[generate_prompt_hash(TEXTS[1])]
|
||||
target_h = new[generate_prompt_hash(TEXTS[3])]
|
||||
self.assertEqual(links[self.rows["A"]], target_a)
|
||||
self.assertEqual(links[self.rows["B"]], self.p2)
|
||||
self.assertEqual(links[self.rows["H"]], target_h)
|
||||
self.assertEqual(links[self.rows["C"]], self.p0, "keep")
|
||||
self.assertEqual(links[self.rows["E"]], self.p0, "skip")
|
||||
self.assertEqual(links[self.rows["G"]], self.p0, "not foreign")
|
||||
self.assertEqual(
|
||||
self.query(
|
||||
"SELECT COUNT(*) FROM image_prompt_pairs WHERE image_id=?",
|
||||
(self.rows["B"],),
|
||||
),
|
||||
[(0,)],
|
||||
)
|
||||
category, notes = self.query(
|
||||
"SELECT category, notes FROM prompts WHERE id=?", (target_a,)
|
||||
)[0]
|
||||
self.assertEqual(category, "scanned")
|
||||
self.assertEqual(
|
||||
notes, f"Auto-scanned from {os.path.basename(self.files['A'])}"
|
||||
)
|
||||
tags = self.query(
|
||||
"SELECT t.name FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id "
|
||||
"WHERE pt.prompt_id=?",
|
||||
(target_a,),
|
||||
)
|
||||
self.assertEqual(tags, [("auto-scanned",)])
|
||||
self.assertIn("integrity_check", out)
|
||||
self.assertIn("foreign_key_check", out)
|
||||
self.assertRegex(out, r"integrity_check\s+ok\s+ok")
|
||||
# Orphans are kept unless the plan chose to delete them
|
||||
self.assertTrue(self.query("SELECT 1 FROM prompts WHERE id=?", (self.orphan,)))
|
||||
|
||||
def test_unique_case_deletes_the_wrong_row(self):
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
links = self.links()
|
||||
self.assertNotIn(self.rows["F"], links)
|
||||
self.assertEqual(links[self.rows["F_ok"]], self.p2)
|
||||
rows = self.query(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?",
|
||||
(self.files["F"],),
|
||||
)
|
||||
self.assertEqual(rows, [(self.p2,)])
|
||||
|
||||
def test_backup_is_taken_before_writing(self):
|
||||
before = self.links()
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
names = os.listdir(self.backups)
|
||||
self.assertEqual(len(names), 1)
|
||||
self.assertRegex(names[0], r"^prompts\.db\.pre-247-repair_\d{8}_\d{6}$")
|
||||
backup = os.path.join(self.backups, names[0])
|
||||
conn = sqlite3.connect(backup)
|
||||
try:
|
||||
saved = dict(conn.execute("SELECT id, prompt_id FROM generated_images"))
|
||||
finally:
|
||||
conn.close()
|
||||
self.assertEqual(saved, before)
|
||||
self.assertIn(f"Backup: {backup}", out)
|
||||
|
||||
def test_backup_beside_the_database_by_default(self):
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
self.db_path,
|
||||
"--apply",
|
||||
self.plan_path,
|
||||
"--comfyui-dir",
|
||||
os.path.join(self.tmp, "comfy"),
|
||||
],
|
||||
out=out,
|
||||
probe=not_running,
|
||||
handles=no_handles,
|
||||
)
|
||||
self.assertEqual(code, 0, out.getvalue())
|
||||
self.assertTrue(
|
||||
any(
|
||||
n.startswith("prompts.db.pre-247-repair_") for n in os.listdir(self.tmp)
|
||||
)
|
||||
)
|
||||
|
||||
def test_refuses_while_comfyui_runs(self):
|
||||
before = file_sha(self.db_path)
|
||||
code, out = self.apply(probe=lambda _d, _p: ["process 42 (main.py)"])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("REFUSED", out)
|
||||
self.assertIn("process 42", out)
|
||||
self.assertEqual(os.listdir(self.backups), [], "no backup either")
|
||||
self.assertEqual(file_sha(self.db_path), before)
|
||||
|
||||
def test_rehearsal_skips_the_running_check_on_a_copy_only(self):
|
||||
code, out = self.apply("--rehearsal", probe=lambda _d, _p: ["port 8188"])
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertIn("rehearsal", out)
|
||||
# The live library of that ComfyUI folder: --rehearsal is refused
|
||||
live_dir = os.path.join(self.tmp, "comfy", "user", "__promptmanager")
|
||||
os.makedirs(live_dir)
|
||||
live = os.path.join(live_dir, "prompts.db")
|
||||
shutil.copy(self.db_path, live)
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[
|
||||
live,
|
||||
"--apply",
|
||||
self.plan_path,
|
||||
"--comfyui-dir",
|
||||
os.path.join(self.tmp, "comfy"),
|
||||
"--rehearsal",
|
||||
],
|
||||
out=out,
|
||||
probe=lambda _d, _p: [],
|
||||
handles=no_handles,
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("--rehearsal", out.getvalue())
|
||||
|
||||
def test_drift_refuses_and_changes_nothing(self):
|
||||
cases = {
|
||||
"row moved": lambda: self.query_write(
|
||||
"UPDATE generated_images SET prompt_id=? WHERE id=?",
|
||||
(self.p2, self.rows["H"]),
|
||||
),
|
||||
"text changed": lambda: self.query_write(
|
||||
"UPDATE prompts SET text='an older guess' WHERE id=?", (self.orphan,)
|
||||
),
|
||||
"file changed": lambda: png(self.files["B"], shapes.branch_bench()),
|
||||
"target stored since": lambda: self.query_write(
|
||||
"INSERT INTO prompts(text, hash) VALUES(?,?)",
|
||||
(TEXTS[1], generate_prompt_hash(TEXTS[1])),
|
||||
),
|
||||
}
|
||||
pristine = os.path.join(self.tmp, "pristine.db")
|
||||
shutil.copy(self.db_path, pristine)
|
||||
image = Path(self.files["B"]).read_bytes()
|
||||
for name, drift in cases.items():
|
||||
with self.subTest(name):
|
||||
shutil.copy(pristine, self.db_path)
|
||||
Path(self.files["B"]).write_bytes(image)
|
||||
shutil.rmtree(self.backups)
|
||||
os.makedirs(self.backups)
|
||||
drift()
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertIn("REFUSED (rolled back, nothing changed)", out)
|
||||
self.assertEqual(dump(self.db_path), before)
|
||||
|
||||
def test_late_failure_rolls_back_every_write(self):
|
||||
plan = self.plan()
|
||||
plan["delete_orphans"] = True
|
||||
self.write_plan(plan)
|
||||
# The safe orphan gained a user tag after the dry run
|
||||
self.query_write("INSERT INTO tags(name) VALUES('mine')", ())
|
||||
self.query_write(
|
||||
"INSERT INTO prompt_tags(prompt_id, tag_id) "
|
||||
"SELECT ?, id FROM tags WHERE name='mine'",
|
||||
(self.orphan,),
|
||||
)
|
||||
before = self.links()
|
||||
prompts = self.query("SELECT COUNT(*) FROM prompts")
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertIn(f"prompt {self.orphan}", out)
|
||||
self.assertIn("user tags", out)
|
||||
self.assertEqual(self.links(), before, "the relinks were rolled back")
|
||||
self.assertEqual(self.query("SELECT COUNT(*) FROM prompts"), prompts)
|
||||
|
||||
def test_orphans_are_deleted_only_when_chosen_and_unreferenced(self):
|
||||
plan = self.plan()
|
||||
plan["delete_orphans"] = True
|
||||
self.write_plan(plan)
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 0, out)
|
||||
self.assertFalse(self.query("SELECT 1 FROM prompts WHERE id=?", (self.orphan,)))
|
||||
self.assertTrue(
|
||||
self.query("SELECT 1 FROM prompts WHERE id=?", (self.rated,)),
|
||||
"a prompt with a rating is never deleted",
|
||||
)
|
||||
self.assertIn("orphans_deleted", out)
|
||||
|
||||
def test_an_applied_plan_refuses_a_second_time(self):
|
||||
self.assertEqual(self.apply()[0], 0)
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("REFUSED", out)
|
||||
self.assertEqual(dump(self.db_path), before)
|
||||
|
||||
|
||||
class ApplySafetyTests(RepairFixture):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
code, out = self.dry_run()
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def assert_unchanged(self, before, code, out):
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertEqual(dump(self.db_path), before)
|
||||
|
||||
def test_refuses_when_another_process_has_the_library_open(self):
|
||||
before = dump(self.db_path)
|
||||
held = lambda _db: ["process 7 has prompts.db-wal open"] # noqa: E731
|
||||
for args in ((), ("--rehearsal",)):
|
||||
with self.subTest(args):
|
||||
code, out = self.apply(*args, handles=held)
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("process 7", out)
|
||||
self.assertEqual(os.listdir(self.backups), [])
|
||||
|
||||
def test_the_running_check_runs_again_just_before_writing(self):
|
||||
calls = []
|
||||
|
||||
def late(_d, _p):
|
||||
calls.append(1)
|
||||
return [] if len(calls) == 1 else ["something listens on port 8188"]
|
||||
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply(probe=late)
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertIn("rolled back", out)
|
||||
self.assertIn("Backup:", out)
|
||||
|
||||
def test_without_proc_only_an_explicit_stop_lets_it_through(self):
|
||||
blind = lambda _db: [tool.NO_PROC] # noqa: E731
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply(handles=blind)
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("--i-stopped-comfyui", out)
|
||||
code, out = self.apply("--i-stopped-comfyui", handles=blind)
|
||||
self.assertEqual(code, 0, out)
|
||||
|
||||
def test_a_folder_without_main_py_is_refused(self):
|
||||
os.remove(os.path.join(self.comfy, "main.py"))
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("main.py", out)
|
||||
|
||||
def test_a_plan_for_another_library_is_refused(self):
|
||||
other = os.path.join(self.tmp, "other.db")
|
||||
shutil.copy(self.db_path, other)
|
||||
out = io.StringIO()
|
||||
code = tool.main(
|
||||
[other, "--apply", self.plan_path, "--comfyui-dir", self.comfy],
|
||||
out=out,
|
||||
probe=not_running,
|
||||
handles=no_handles,
|
||||
)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("made for", out.getvalue())
|
||||
|
||||
def test_opening_the_library_must_not_change_its_schema(self):
|
||||
self.query_write("DROP INDEX idx_prompt_pairs_positive_only", ())
|
||||
PromptModel.reset_schema_cache()
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 2, out)
|
||||
self.assertIn("schema", out)
|
||||
self.assertIn("live code version", out)
|
||||
|
||||
def test_an_existing_backup_file_is_refused(self):
|
||||
stamp = "20261028_000000"
|
||||
Path(self.backups, f"prompts.db.pre-247-repair_{stamp}").write_bytes(b"x")
|
||||
before = dump(self.db_path)
|
||||
with unittest.mock.patch.object(tool, "_stamp", return_value=stamp):
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("already exists", out)
|
||||
|
||||
def test_a_backup_that_does_not_match_is_refused(self):
|
||||
real = tool.backup_database
|
||||
|
||||
def short(source, target):
|
||||
real(source, target)
|
||||
conn = sqlite3.connect(target)
|
||||
try:
|
||||
with conn:
|
||||
conn.execute("DELETE FROM image_prompt_pairs")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
before = dump(self.db_path)
|
||||
with unittest.mock.patch.object(tool, "backup_database", short):
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("backup", out)
|
||||
|
||||
def test_a_changed_target_text_is_refused(self):
|
||||
plan = self.plan()
|
||||
for item in plan["rows"]:
|
||||
if item["row_id"] == self.rows["A"]:
|
||||
item["target_text"] = "something else"
|
||||
self.write_plan(plan)
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("target_text", out)
|
||||
|
||||
def test_an_orphan_gone_or_changed_at_apply_is_refused(self):
|
||||
plan = self.plan()
|
||||
plan["delete_orphans"] = True
|
||||
plan["orphans"][0]["text_sha256"] = "0" * 64
|
||||
self.write_plan(plan)
|
||||
before = dump(self.db_path)
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("gone or text changed", out)
|
||||
|
||||
def test_any_error_rolls_back_and_names_the_backup(self):
|
||||
before = dump(self.db_path)
|
||||
with unittest.mock.patch.object(
|
||||
tool, "_relink", side_effect=RuntimeError("disk on fire")
|
||||
):
|
||||
code, out = self.apply()
|
||||
self.assert_unchanged(before, code, out)
|
||||
self.assertIn("rolled back", out)
|
||||
self.assertIn("disk on fire", out)
|
||||
self.assertIn(self.backups, out)
|
||||
|
||||
def test_a_failed_integrity_check_after_writing_exits_1(self):
|
||||
real = tool.counts
|
||||
seen = []
|
||||
|
||||
def counts(path, readonly=True):
|
||||
found = real(path, readonly)
|
||||
if path == self.db_path:
|
||||
seen.append(path)
|
||||
if len(seen) > 1:
|
||||
return {**found, "integrity_check": "corrupt"}
|
||||
return found
|
||||
|
||||
with unittest.mock.patch.object(tool, "counts", counts):
|
||||
code, out = self.apply()
|
||||
self.assertEqual(code, 1, out)
|
||||
self.assertIn("restore", out)
|
||||
|
||||
|
||||
class BlockerTests(RepairFixture):
|
||||
"""Each reference that keeps a prompt from being deleted."""
|
||||
|
||||
def test_every_blocker(self):
|
||||
pid = self.orphan
|
||||
cases = {
|
||||
"linked images": None,
|
||||
"saved pairs": "INSERT INTO prompt_pairs(positive_prompt_id) VALUES(:id)",
|
||||
"recorded runs": "UPDATE execution_run_contexts "
|
||||
"SET positive_prompt_id=:id",
|
||||
"library state": "INSERT INTO prompt_library_state(prompt_id) "
|
||||
"VALUES(:id)",
|
||||
"user tags": "INSERT INTO prompt_tags(prompt_id, tag_id) "
|
||||
"SELECT :id, id FROM tags WHERE name='mine'",
|
||||
"usage or rating": "UPDATE prompts SET run_count=3 WHERE id=:id",
|
||||
"category": "UPDATE prompts SET category='portraits' WHERE id=:id",
|
||||
"notes": "UPDATE prompts SET notes='mine' WHERE id=:id",
|
||||
}
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
conn.execute("INSERT INTO tags(name) VALUES('mine')")
|
||||
conn.commit()
|
||||
for name, sql in cases.items():
|
||||
with self.subTest(name):
|
||||
conn.execute("SAVEPOINT blocker")
|
||||
if sql is None:
|
||||
unused = {pid}
|
||||
else:
|
||||
conn.execute(
|
||||
"DELETE FROM generated_images WHERE prompt_id=?", (pid,)
|
||||
)
|
||||
conn.execute(sql, {"id": pid})
|
||||
unused = {pid}
|
||||
found = tool.blockers(conn, pid, unused)
|
||||
self.assertTrue(any(item.startswith(name) for item in found), found)
|
||||
conn.execute("ROLLBACK TO blocker")
|
||||
conn.execute("RELEASE blocker")
|
||||
conn.execute("DELETE FROM generated_images WHERE prompt_id=?", (pid,))
|
||||
self.assertEqual(tool.blockers(conn, pid, {pid}), [])
|
||||
self.assertEqual(
|
||||
tool.blockers(conn, pid, set()), ["not unused by the app's rule"]
|
||||
)
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _off_text_node(text, save=False):
|
||||
return {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": text, "save_to_library": save},
|
||||
}
|
||||
|
||||
|
||||
def _encode_from(source):
|
||||
return {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": source, "clip": ["1", 1]},
|
||||
}
|
||||
|
||||
|
||||
def off_behind_pass_through(branch=2):
|
||||
"""The dataset graph with ``branch``'s text coming from a switched-off
|
||||
PromptManagerText through a pass-through the #90 walk does not follow."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = _off_text_node(TEXTS[branch - 1])
|
||||
graph["51"] = {"class_type": "MyNode", "inputs": {"value": ["50", 0]}}
|
||||
graph[str(branch * 10)] = _encode_from(["51", 0])
|
||||
return graph
|
||||
|
||||
|
||||
def off_target(branch=2, save=False):
|
||||
"""``branch``'s text straight from a PromptManagerText whose switch is
|
||||
``save`` (False, or a link to a node the graph cannot read)."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = _off_text_node(TEXTS[branch - 1], save)
|
||||
graph[str(branch * 10)] = _encode_from(["50", 0])
|
||||
if isinstance(save, list):
|
||||
graph["52"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
return graph
|
||||
|
||||
|
||||
def off_negative_only():
|
||||
"""Every branch's negative from a switched-off PromptManagerText."""
|
||||
graph = shapes.shared_prefix()
|
||||
graph["50"] = _off_text_node(shapes.NEGATIVE)
|
||||
graph["3"] = _encode_from(["50", 0])
|
||||
return graph
|
||||
|
||||
|
||||
class SaveToLibraryTests(RecordedRuns, unittest.TestCase):
|
||||
"""#90: the repair never relinks a file to a switched-off node's text.
|
||||
|
||||
The plan decides each file through ``decide_file_prompt``; every answer
|
||||
but a positive from the recorded saver or the inspector is a skip, so a
|
||||
``not_saved`` or ``not_saved_wired`` file is never written. Each file is
|
||||
reported by a run without contexts (as a cached or unrecorded run is),
|
||||
so only the ladder can stop it. Saver "23" is branch 2.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
self.addCleanup(assert_closed, self, self.tmp)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.output = os.path.join(self.tmp, "output")
|
||||
os.makedirs(os.path.join(self.output, "dataset"))
|
||||
self.db_path = os.path.join(self.tmp, "prompts.db")
|
||||
|
||||
def plan_for(self, graph):
|
||||
path = os.path.join(self.output, "dataset", NAME.format(1))
|
||||
png(path, graph)
|
||||
db = PromptDatabase(self.db_path)
|
||||
try:
|
||||
wrong = db.save_prompt(
|
||||
text=TEXTS[0], prompt_hash=generate_prompt_hash(TEXTS[0])
|
||||
)
|
||||
db.link_image_to_prompt(wrong, path, metadata={})
|
||||
self.record_run(
|
||||
db, self.output, [("dataset", NAME.format(1))], [], nodes=["23"]
|
||||
)
|
||||
plan = tool.build_plan(db, self.output, self.db_path)
|
||||
finally:
|
||||
db.close_all()
|
||||
(item,) = plan["rows"]
|
||||
return item
|
||||
|
||||
def test_an_off_node_behind_a_pass_through_is_skipped(self):
|
||||
item = self.plan_for(off_behind_pass_through())
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["decision"], "not_saved/none")
|
||||
self.assertNotIn("target_text", item)
|
||||
|
||||
def test_an_off_node_in_the_recorded_run_target_is_skipped(self):
|
||||
cases = {
|
||||
"off": (False, "not_saved/none"),
|
||||
"wired": (["52", 0], "not_saved_wired/none"),
|
||||
}
|
||||
for name, (save, decision) in cases.items():
|
||||
with self.subTest(name):
|
||||
if os.path.exists(self.db_path):
|
||||
os.remove(self.db_path)
|
||||
item = self.plan_for(off_target(save=save))
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["decision"], decision)
|
||||
|
||||
def test_an_off_node_on_another_branch_still_skips_the_file(self):
|
||||
"""The ladder judges the file's whole graph: any switched-off text
|
||||
upstream of a positive makes the file not saved."""
|
||||
item = self.plan_for(off_target(branch=3))
|
||||
self.assertEqual(item["action"], "skip")
|
||||
self.assertEqual(item["decision"], "not_saved/none")
|
||||
|
||||
def test_an_off_node_wired_only_to_the_negative_still_repairs(self):
|
||||
item = self.plan_for(off_negative_only())
|
||||
self.assertEqual(item["action"], "relink")
|
||||
self.assertEqual(item["source"], "recorded-run")
|
||||
self.assertEqual(item["target_text"], TEXTS[1])
|
||||
|
||||
def test_a_switched_on_node_in_the_target_still_repairs(self):
|
||||
"""Regression guard: a switch that is on is no reason to skip."""
|
||||
item = self.plan_for(off_target(save=True))
|
||||
self.assertEqual(item["action"], "relink")
|
||||
self.assertEqual(item["target_text"], TEXTS[1])
|
||||
|
||||
|
||||
class ActionTests(unittest.TestCase):
|
||||
"""Only the recorded saver relinks; a certain step naming the linked
|
||||
prompt keeps it; the legacy floor and the rest are skipped."""
|
||||
|
||||
def test_action_by_decision(self):
|
||||
linked = generate_prompt_hash("linked")
|
||||
cases = [
|
||||
(Decision("positive", "other", source="recorded-run"), "relink"),
|
||||
(Decision("positive", "linked", source="recorded-run"), "keep"),
|
||||
(Decision("positive", "linked", source="inspector"), "keep"),
|
||||
(Decision("positive", "other", source="inspector"), "skip"),
|
||||
(Decision("positive", "linked", source="legacy-parser"), "skip"),
|
||||
(Decision("pair", "linked", source="resolver"), "skip"),
|
||||
(Decision("never"), "skip"),
|
||||
(Decision("not_saved"), "skip"),
|
||||
(Decision("not_saved_wired"), "skip"),
|
||||
(Decision("positive", "other", source="opted-out-negative"), "skip"),
|
||||
]
|
||||
for decision, action in cases:
|
||||
with self.subTest(decision.source, text=decision.text):
|
||||
self.assertEqual(tool.action_of(decision, linked), action)
|
||||
|
||||
|
||||
class CommandLineTests(unittest.TestCase):
|
||||
def test_running_probe_reports_a_listening_port(self):
|
||||
import socket
|
||||
|
||||
with socket.socket() as server:
|
||||
server.bind(("127.0.0.1", 0))
|
||||
server.listen(1)
|
||||
port = server.getsockname()[1]
|
||||
found = tool.comfyui_running("/nonexistent/comfy", port)
|
||||
self.assertTrue(any(str(port) in item for item in found))
|
||||
|
||||
def test_running_probe_is_quiet_when_nothing_runs(self):
|
||||
import socket
|
||||
|
||||
with socket.socket() as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
port = probe.getsockname()[1]
|
||||
self.assertEqual(tool.comfyui_running("/nonexistent/comfy", port), [])
|
||||
|
||||
def fake_proc(self, tmp):
|
||||
proc = Path(tmp, "proc")
|
||||
db = Path(tmp, "prompts.db")
|
||||
db.write_bytes(b"")
|
||||
Path(tmp, "prompts.db-wal").write_bytes(b"")
|
||||
comfy = Path(tmp, "comfy")
|
||||
comfy.mkdir()
|
||||
holder = proc / "125" / "fd"
|
||||
holder.mkdir(parents=True)
|
||||
os.symlink(Path(tmp, "prompts.db-wal"), holder / "3")
|
||||
(proc / "125" / "cmdline").write_bytes(b"python\0main.py\0")
|
||||
os.symlink(comfy, proc / "125" / "cwd")
|
||||
(proc / "123").mkdir() # unreadable: no fd, cmdline or cwd
|
||||
(proc / "self").mkdir() # not a pid
|
||||
return proc, db, comfy
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows")
|
||||
def test_proc_scan_finds_holders_and_skips_unreadable_entries(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
proc, db, comfy = self.fake_proc(tmp)
|
||||
found = tool.open_by_others(str(db), proc=str(proc), own_pid=1)
|
||||
self.assertEqual(len(found), 1)
|
||||
self.assertIn("125", found[0])
|
||||
self.assertEqual(
|
||||
tool.open_by_others(str(db), proc=str(proc), own_pid=125), []
|
||||
)
|
||||
running = tool.comfyui_running(str(comfy), 1, proc=str(proc))
|
||||
self.assertTrue(any("125" in item for item in running), running)
|
||||
|
||||
def test_without_proc_the_scan_says_it_cannot_tell(self):
|
||||
self.assertEqual(
|
||||
tool.open_by_others("/x/prompts.db", proc="/nonexistent/proc"),
|
||||
[tool.NO_PROC],
|
||||
)
|
||||
|
||||
def test_missing_database_is_an_argument_error(self):
|
||||
with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()):
|
||||
tool.main(
|
||||
["/nonexistent/prompts.db", "--output-dir", "/tmp"], out=io.StringIO()
|
||||
)
|
||||
|
||||
def test_apply_needs_the_comfyui_folder(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db = os.path.join(tmp, "prompts.db")
|
||||
Path(db).write_bytes(b"")
|
||||
with (
|
||||
self.assertRaises(SystemExit),
|
||||
contextlib.redirect_stderr(io.StringIO()),
|
||||
):
|
||||
tool.main([db, "--apply", "plan.json"], out=io.StringIO())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Is ComfyUI stopped? The checks a library repair runs before writing (#247).
|
||||
|
||||
``comfyui_running`` looks for ``main.py`` processes of one ComfyUI folder
|
||||
and a listener on its port. ``open_by_others`` lists the processes that hold
|
||||
the library file, its ``-wal`` or its ``-shm`` open. Both read ``/proc``
|
||||
(Linux); without it ``open_by_others`` answers ``[NO_PROC]``: it cannot
|
||||
tell, and the caller refuses unless the user says ComfyUI is stopped.
|
||||
Entries that cannot be read (another user's process, one that just exited)
|
||||
are skipped.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
|
||||
NO_PROC = (
|
||||
"cannot list the processes that hold the library open (no /proc): stop "
|
||||
"ComfyUI and pass --i-stopped-comfyui"
|
||||
)
|
||||
|
||||
|
||||
def _same(path):
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def _pids(proc, own_pid=None):
|
||||
try:
|
||||
names = os.listdir(proc)
|
||||
except OSError:
|
||||
return []
|
||||
return [name for name in names if name.isdigit() and name != str(own_pid)]
|
||||
|
||||
|
||||
def comfyui_running(comfyui_dir, port, proc="/proc"):
|
||||
"""``main.py`` processes running from ``comfyui_dir`` and a listener on
|
||||
``port`` (127.0.0.1), each as one line; empty when none is found."""
|
||||
found = []
|
||||
folder = _same(comfyui_dir)
|
||||
for pid in _pids(proc):
|
||||
try:
|
||||
with open(os.path.join(proc, pid, "cmdline"), "rb") as stream:
|
||||
parts = stream.read().split(b"\0")
|
||||
cwd = _same(os.readlink(os.path.join(proc, pid, "cwd")))
|
||||
except OSError:
|
||||
continue
|
||||
if any(part.endswith(b"main.py") for part in parts) and (
|
||||
cwd == folder or any(folder.encode() in part for part in parts)
|
||||
):
|
||||
found.append(f"process {pid} (main.py in {cwd})")
|
||||
with socket.socket() as probe:
|
||||
probe.settimeout(1)
|
||||
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
||||
found.append(f"something listens on port {port}")
|
||||
return found
|
||||
|
||||
|
||||
def open_by_others(database, proc="/proc", own_pid=None):
|
||||
"""Processes other than ``own_pid`` (default: this one) holding
|
||||
``database``, its ``-wal`` or ``-shm`` open; ``[NO_PROC]`` without /proc."""
|
||||
if not os.path.isdir(proc):
|
||||
return [NO_PROC]
|
||||
own_pid = os.getpid() if own_pid is None else own_pid
|
||||
targets = {_same(database + suffix): suffix for suffix in ("", "-wal", "-shm")}
|
||||
found = []
|
||||
for pid in _pids(proc, own_pid):
|
||||
fd_dir = os.path.join(proc, pid, "fd")
|
||||
try:
|
||||
fds = os.listdir(fd_dir)
|
||||
except OSError:
|
||||
continue
|
||||
held = set()
|
||||
for fd in fds:
|
||||
try:
|
||||
target = os.readlink(os.path.join(fd_dir, fd))
|
||||
except OSError:
|
||||
continue
|
||||
target = target.replace(" (deleted)", "")
|
||||
if os.path.isabs(target) and _same(target) in targets:
|
||||
held.add(os.path.basename(database) + targets[_same(target)])
|
||||
if held:
|
||||
found.append(f"process {pid} has {', '.join(sorted(held))} open")
|
||||
return found
|
||||
@@ -0,0 +1,776 @@
|
||||
"""Repair the links the old scan gave foreign dataset files (#247), on review.
|
||||
|
||||
Before #247 the output scan linked each file of a foreign run (a workflow
|
||||
with no PromptManager node) through the legacy parser, so every file of a
|
||||
shared-prefix dataset run got the first branch's text. Recorded history
|
||||
knows which save node wrote each file; the shared ladder
|
||||
(``utils.file_prompt.decide_file_prompt``, scan policy) uses it
|
||||
(``recorded-run``) to name the right branch. This tool judges every
|
||||
``generated_images`` row of a file whose recording runs all have no
|
||||
PromptManager context:
|
||||
|
||||
``relink`` the ladder names a positive by the recorded saver, and its text
|
||||
is not the linked prompt's
|
||||
``keep`` the recorded saver or the inspector names the linked prompt
|
||||
``skip`` anything else (file gone, no single recorded saver, ambiguous,
|
||||
the legacy floor or another step decided)
|
||||
|
||||
Dry run (the default) judges a copy of the database (SQLite backup from a
|
||||
read-only connection) and writes a JSON plan and a Markdown list beside it
|
||||
(``<plan stem>.list.md``). It also rehearses the apply on a second copy, to
|
||||
list the prompts that would lose every link with their remaining
|
||||
references. Deleting those is OFF unless the dry run is given
|
||||
``--delete-orphans``; the choice is recorded in the plan and rehearsed on a
|
||||
third copy. The library is never written.
|
||||
|
||||
``--apply <plan.json>`` (ComfyUI stopped), only on the library the plan was
|
||||
made from (its real path, recorded in the plan):
|
||||
|
||||
1. refuses unless ``--comfyui-dir`` holds ``main.py``; refuses while a
|
||||
``main.py`` runs from it, anything listens on ``--port``, or any other
|
||||
process holds the library, its ``-wal`` or ``-shm`` open
|
||||
(``tools/comfyui_stopped.py``; without /proc only with
|
||||
``--i-stopped-comfyui``). ``--rehearsal`` skips the process and port
|
||||
check, never the open-file check, and never for that folder's library;
|
||||
2. saves ``<db>.pre-247-repair_<stamp>`` (or into ``--backup-dir``), never
|
||||
over an existing file, and refuses unless the backup passes
|
||||
``integrity_check`` with the library's counts. Opening the library must
|
||||
not change its schema (a newer checkout's migration): run the tool from a
|
||||
checkout at the live code version. The running check runs once more just
|
||||
before writing;
|
||||
3. in ONE ``BEGIN IMMEDIATE`` transaction: re-judges every planned row and
|
||||
refuses on any drift (row moved, prompt text changed, file changed, target
|
||||
stored since), creates the target prompts that do not exist (category
|
||||
``scanned``, tag ``auto-scanned``, note ``Auto-scanned from <first file>``),
|
||||
points each row at its target and drops its pair links (they named the old
|
||||
positive). A file that already has a row for the target keeps that row and
|
||||
loses the wrong one (UNIQUE(prompt_id, file_path)). Orphans are deleted only
|
||||
when the plan chose it and each is still unreferenced.
|
||||
|
||||
Any failed check rolls everything back and exits 2. Before/after table
|
||||
counts, ``integrity_check`` and ``foreign_key_check`` are printed.
|
||||
|
||||
python3 tools/repair_foreign_links.py <db> --output-dir <ComfyUI output> \\
|
||||
--plan repair_plan.json
|
||||
python3 tools/repair_foreign_links.py <db> --apply repair_plan.json \\
|
||||
--comfyui-dir <ComfyUI folder>
|
||||
|
||||
Run from a checkout at the live code version (after the merge: main) with
|
||||
its venv.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
from database.maintenance import ORPHANED_PROMPTS_SQL, _delete_ids # noqa: E402
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
from database.pair_ingestion import ( # noqa: E402
|
||||
SCAN_MARKS,
|
||||
file_link_rows,
|
||||
mark_created,
|
||||
)
|
||||
from database.run_attribution import recorded_output # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from tools.comfyui_stopped import ( # noqa: E402
|
||||
NO_PROC,
|
||||
comfyui_running,
|
||||
open_by_others,
|
||||
)
|
||||
from tools.review_image_links import backup_database # noqa: E402
|
||||
from utils.execution_outputs import snapshot_output_roots # noqa: E402
|
||||
from utils.file_prompt import POSITIVE, Policy, decide_file_prompt # noqa: E402
|
||||
from utils.hashing import generate_prompt_hash # noqa: E402
|
||||
|
||||
PLAN_VERSION = 1
|
||||
COMFY_PORT = 8188
|
||||
LIVE_LIBRARY = ("user", "__promptmanager", "prompts.db")
|
||||
EXCERPT = 80
|
||||
COUNTED = (
|
||||
"prompts",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"image_prompt_pairs",
|
||||
"workflow_documents",
|
||||
"prompt_pairs",
|
||||
"prompt_library_state",
|
||||
"execution_run_contexts",
|
||||
"tags",
|
||||
)
|
||||
# The ladder steps whose answer is certain enough to keep a link.
|
||||
KEEP_SOURCES = ("recorded-run", "inspector")
|
||||
# What must be unchanged since the dry run for a row to be written.
|
||||
DRIFT_KEYS = (
|
||||
"action",
|
||||
"file",
|
||||
"current_prompt_id",
|
||||
"current_text_sha256",
|
||||
"file_size",
|
||||
"file_mtime_ns",
|
||||
"target_text",
|
||||
"target_hash",
|
||||
"target_prompt_id",
|
||||
)
|
||||
# Locations reported only by runs without any PromptManager context.
|
||||
_FOREIGN_LOCATIONS = (
|
||||
"SELECT o.subfolder, o.filename FROM execution_run_outputs o "
|
||||
"WHERE o.folder_type = 'output' AND o.root_key = ? AND o.path_style = ? "
|
||||
"GROUP BY o.subfolder, o.filename "
|
||||
"HAVING SUM(EXISTS (SELECT 1 FROM execution_run_contexts c "
|
||||
"WHERE c.run_id = o.run_id)) = 0 "
|
||||
"ORDER BY o.subfolder, o.filename"
|
||||
)
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
"""A check failed: nothing is written."""
|
||||
|
||||
|
||||
def sha256(text):
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def metadata_reader():
|
||||
"""The scan's metadata extraction (PNG text chunks, ffprobe for videos)."""
|
||||
api = SimpleNamespace(logger=logging.getLogger("prompt_manager.repair_247"))
|
||||
return lambda path: PromptManagerAPI._extract_comfyui_metadata(api, path) or {}
|
||||
|
||||
|
||||
def foreign_rows(db, output_dir):
|
||||
"""The link rows of every file only foreign runs reported, by id."""
|
||||
style = "windows" if os.name == "nt" else "posix"
|
||||
root_key = snapshot_output_roots(style, output_dir, output_dir).output_key
|
||||
conn = db.model.get_connection()
|
||||
rows = {}
|
||||
for subfolder, filename in conn.execute(_FOREIGN_LOCATIONS, (root_key, style)):
|
||||
path = os.path.join(output_dir, subfolder, filename)
|
||||
rows.update((row["id"], row) for row in file_link_rows(conn, path))
|
||||
return [rows[key] for key in sorted(rows)]
|
||||
|
||||
|
||||
class Judge:
|
||||
"""One file's decision per path, for all of its rows."""
|
||||
|
||||
def __init__(self, db, output_dir, read=None):
|
||||
self.db = db
|
||||
self.output_dir = output_dir
|
||||
self.read = read or metadata_reader()
|
||||
self.decisions = {}
|
||||
|
||||
def decide(self, path, size):
|
||||
if path not in self.decisions:
|
||||
self.decisions[path] = decide_file_prompt(
|
||||
self.read(path),
|
||||
file_name=os.path.basename(path),
|
||||
file_size=size,
|
||||
in_output=True,
|
||||
lookup=lambda: recorded_output(self.db, path, self.output_dir),
|
||||
policy=Policy.SCAN,
|
||||
)
|
||||
return self.decisions[path]
|
||||
|
||||
def row(self, row):
|
||||
"""The plan entry of one ``generated_images`` row."""
|
||||
conn = self.db.model.get_connection()
|
||||
path = row["image_path"]
|
||||
(text,) = conn.execute(
|
||||
"SELECT text FROM prompts WHERE id = ?", (row["prompt_id"],)
|
||||
).fetchone()
|
||||
prompt_hash = generate_prompt_hash(text)
|
||||
item = {
|
||||
"row_id": row["id"],
|
||||
"file": path,
|
||||
"current_prompt_id": row["prompt_id"],
|
||||
"current_text_sha256": sha256(text),
|
||||
}
|
||||
try:
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
return {**item, "action": "skip", "reason": "file gone"}
|
||||
item.update(file_size=stat.st_size, file_mtime_ns=stat.st_mtime_ns)
|
||||
decision = self.decide(path, stat.st_size)
|
||||
action = action_of(decision, prompt_hash)
|
||||
if action == "skip":
|
||||
return {
|
||||
**item,
|
||||
"action": "skip",
|
||||
"reason": "not decided by the recorded saver",
|
||||
"decision": f"{decision.kind}/{decision.source}",
|
||||
}
|
||||
target_hash = generate_prompt_hash(decision.text)
|
||||
stored = conn.execute(
|
||||
"SELECT id FROM prompts WHERE hash = ?", (target_hash,)
|
||||
).fetchone()
|
||||
item.update(
|
||||
target_text=decision.text,
|
||||
target_hash=target_hash,
|
||||
target_prompt_id=stored[0] if stored else "new",
|
||||
action=action,
|
||||
source=decision.source,
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
def action_of(decision, linked_hash):
|
||||
"""``relink``, ``keep`` or ``skip`` for a row linked to ``linked_hash``.
|
||||
|
||||
Only the recorded saver (``recorded-run``) names a new target. A certain
|
||||
step (the recorded saver or the inspector) naming the linked prompt keeps
|
||||
it; the legacy floor and every other answer are skipped.
|
||||
"""
|
||||
if decision.kind != POSITIVE or decision.source not in KEEP_SOURCES:
|
||||
return "skip"
|
||||
if generate_prompt_hash(decision.text) == linked_hash:
|
||||
return "keep"
|
||||
return "relink" if decision.source == "recorded-run" else "skip"
|
||||
|
||||
|
||||
def library_identity(path):
|
||||
"""How a plan names its library: the case-normalized real path."""
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def build_plan(db, output_dir, source_db, read=None):
|
||||
"""The plan for ``db`` (a copy of ``source_db``)."""
|
||||
judge = Judge(db, output_dir, read)
|
||||
conn = db.model.get_connection()
|
||||
(max_row_id,) = conn.execute("SELECT MAX(id) FROM generated_images").fetchone()
|
||||
return {
|
||||
"version": PLAN_VERSION,
|
||||
"created": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
"source_db": library_identity(source_db),
|
||||
"max_row_id": max_row_id,
|
||||
"output_dir": output_dir,
|
||||
"rows": [judge.row(row) for row in foreign_rows(db, output_dir)],
|
||||
"orphans": [],
|
||||
"delete_orphans": False,
|
||||
}
|
||||
|
||||
|
||||
def blockers(conn, prompt_id, unused):
|
||||
"""Why ``prompt_id`` must not be deleted (empty: nothing references it)."""
|
||||
found = []
|
||||
values = {"id": prompt_id, "tag": SCAN_MARKS.tags[0]}
|
||||
checks = {
|
||||
"linked images": "SELECT COUNT(*) FROM generated_images WHERE prompt_id=:id",
|
||||
"saved pairs": "SELECT COUNT(*) FROM prompt_pairs "
|
||||
"WHERE positive_prompt_id=:id OR negative_prompt_id=:id",
|
||||
"recorded runs": "SELECT COUNT(*) FROM execution_run_contexts "
|
||||
"WHERE positive_prompt_id=:id OR negative_prompt_id=:id",
|
||||
"library state": "SELECT COUNT(*) FROM prompt_library_state "
|
||||
"WHERE prompt_id=:id",
|
||||
"user tags": "SELECT COUNT(*) FROM prompt_tags pt JOIN tags t "
|
||||
"ON t.id=pt.tag_id WHERE pt.prompt_id=:id AND t.name != :tag",
|
||||
}
|
||||
for name, sql in checks.items():
|
||||
count = conn.execute(sql, values).fetchone()[0]
|
||||
if count:
|
||||
found.append(f"{name}: {count}")
|
||||
run_count, last_used, rating, category, notes = conn.execute(
|
||||
"SELECT run_count, last_used_at, rating, category, notes FROM prompts "
|
||||
"WHERE id=?",
|
||||
(prompt_id,),
|
||||
).fetchone()
|
||||
if run_count or last_used or rating:
|
||||
found.append("usage or rating")
|
||||
if category not in (None, SCAN_MARKS.category):
|
||||
found.append(f"category {category}")
|
||||
if notes and not notes.startswith("Auto-scanned from "):
|
||||
found.append("notes")
|
||||
if prompt_id not in unused:
|
||||
found.append("not unused by the app's rule")
|
||||
return found
|
||||
|
||||
|
||||
def _rejudge(conn, judge, plan):
|
||||
for item in plan["rows"]:
|
||||
if item["action"] == "skip":
|
||||
continue
|
||||
row = conn.execute(
|
||||
"SELECT * FROM generated_images WHERE id = ?", (item["row_id"],)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise Refused(f"row {item['row_id']}: gone")
|
||||
if generate_prompt_hash(item.get("target_text") or "") != item.get(
|
||||
"target_hash"
|
||||
):
|
||||
raise Refused(f"row {item['row_id']}: target_text does not match its hash")
|
||||
fresh = judge.row(row)
|
||||
for key in DRIFT_KEYS:
|
||||
if fresh.get(key) != item.get(key):
|
||||
raise Refused(
|
||||
f"row {item['row_id']}: {key} changed "
|
||||
f"({item.get(key)!r} -> {fresh.get(key)!r})"
|
||||
)
|
||||
|
||||
|
||||
def _targets(conn, db, relinks):
|
||||
"""``{target hash: prompt id}``, creating each "new" target once."""
|
||||
targets = {}
|
||||
for item in relinks:
|
||||
digest = item["target_hash"]
|
||||
if digest in targets:
|
||||
continue
|
||||
if item["target_prompt_id"] != "new":
|
||||
targets[digest] = item["target_prompt_id"]
|
||||
continue
|
||||
prompt_id, created = db._pair_component_outcome(conn, item["target_text"])
|
||||
if not created:
|
||||
raise Refused(f"row {item['row_id']}: target prompt already stored")
|
||||
mark_created(conn, db, prompt_id, SCAN_MARKS, os.path.basename(item["file"]))
|
||||
targets[digest] = prompt_id
|
||||
return targets
|
||||
|
||||
|
||||
def _relink(conn, item, target):
|
||||
"""Point the row at ``target``; True when the wrong row was deleted instead."""
|
||||
row_id = item["row_id"]
|
||||
correct = [
|
||||
row
|
||||
for row in file_link_rows(conn, item["file"])
|
||||
if row["prompt_id"] == target and row["id"] != row_id
|
||||
]
|
||||
if correct: # UNIQUE(prompt_id, file_path): the correct row stays alone
|
||||
if _delete_ids(conn, "generated_images", [row_id]) != 1:
|
||||
raise Refused(f"row {row_id}: delete touched no row")
|
||||
return True
|
||||
# A pair link records the old positive: it goes with the old link
|
||||
conn.execute("DELETE FROM image_prompt_pairs WHERE image_id = ?", (row_id,))
|
||||
cursor = conn.execute(
|
||||
"UPDATE generated_images SET prompt_id = ? WHERE id = ? AND prompt_id = ?",
|
||||
(target, row_id, item["current_prompt_id"]),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise Refused(f"row {row_id}: relink touched {cursor.rowcount} rows")
|
||||
return False
|
||||
|
||||
|
||||
def _audit(conn, relinks):
|
||||
"""The prompts the relinks leave without any link, with their blockers."""
|
||||
sources = {}
|
||||
for item in relinks:
|
||||
sources.setdefault(item["current_prompt_id"], []).append(item)
|
||||
unused = {row[0] for row in conn.execute(ORPHANED_PROMPTS_SQL)}
|
||||
audit = []
|
||||
for prompt_id in sorted(sources):
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM generated_images WHERE prompt_id = ?", (prompt_id,)
|
||||
).fetchone():
|
||||
continue
|
||||
(text,) = conn.execute(
|
||||
"SELECT text FROM prompts WHERE id = ?", (prompt_id,)
|
||||
).fetchone()
|
||||
found = blockers(conn, prompt_id, unused)
|
||||
audit.append(
|
||||
{
|
||||
"id": prompt_id,
|
||||
"text_excerpt": " ".join(text.split())[:EXCERPT],
|
||||
"text_sha256": sha256(text),
|
||||
"images_before": len(sources[prompt_id]),
|
||||
"blockers": found,
|
||||
"safe": not found,
|
||||
}
|
||||
)
|
||||
return audit
|
||||
|
||||
|
||||
def _delete_orphans(conn, plan):
|
||||
chosen = [orphan for orphan in plan["orphans"] if orphan["safe"]]
|
||||
unused = {row[0] for row in conn.execute(ORPHANED_PROMPTS_SQL)}
|
||||
for orphan in chosen:
|
||||
row = conn.execute(
|
||||
"SELECT text FROM prompts WHERE id = ?", (orphan["id"],)
|
||||
).fetchone()
|
||||
if row is None or sha256(row[0]) != orphan["text_sha256"]:
|
||||
raise Refused(f"prompt {orphan['id']}: gone or text changed")
|
||||
found = blockers(conn, orphan["id"], unused)
|
||||
if found:
|
||||
raise Refused(f"prompt {orphan['id']}: {', '.join(found)}")
|
||||
ids = [orphan["id"] for orphan in chosen]
|
||||
if _delete_ids(conn, "prompts", ids) != len(ids):
|
||||
raise Refused("an orphan prompt was not deleted")
|
||||
return len(ids)
|
||||
|
||||
|
||||
def apply_plan(db, plan, read=None, before_write=None):
|
||||
"""Write ``plan`` in one transaction; returns ``(result, orphan audit)``.
|
||||
|
||||
``before_write()`` runs just before ``BEGIN IMMEDIATE`` (the running
|
||||
check). Raises ``Refused`` (and rolls back) on any drift or failed check.
|
||||
"""
|
||||
judge = Judge(db, plan["output_dir"], read)
|
||||
relinks = [item for item in plan["rows"] if item["action"] == "relink"]
|
||||
if before_write is not None:
|
||||
before_write()
|
||||
with db._pair_transaction() as conn:
|
||||
_rejudge(conn, judge, plan)
|
||||
targets = _targets(conn, db, relinks)
|
||||
dropped = sum(
|
||||
_relink(conn, item, targets[item["target_hash"]]) for item in relinks
|
||||
)
|
||||
audit = _audit(conn, relinks)
|
||||
deleted = _delete_orphans(conn, plan) if plan.get("delete_orphans") else 0
|
||||
created = sum(1 for item in relinks if item["target_prompt_id"] == "new")
|
||||
result = {
|
||||
"relinked": len(relinks) - dropped,
|
||||
"duplicates_dropped": dropped,
|
||||
"prompts_created": len(
|
||||
{i["target_hash"] for i in relinks if i["target_prompt_id"] == "new"}
|
||||
),
|
||||
"rows_to_new_prompts": created,
|
||||
"kept": sum(1 for item in plan["rows"] if item["action"] == "keep"),
|
||||
"orphans_deleted": deleted,
|
||||
}
|
||||
return result, audit
|
||||
|
||||
|
||||
def counts(path, readonly=True):
|
||||
"""Table counts plus the integrity and foreign-key checks.
|
||||
|
||||
Read-only for a library; the tool's own backup is opened read-write so
|
||||
a WAL file leaves no side files behind once it closes.
|
||||
"""
|
||||
uri = Path(os.path.abspath(path)).as_uri() + ("?mode=ro" if readonly else "")
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
found = {
|
||||
table: conn.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
|
||||
for table in COUNTED # nosec B608 - fixed table names
|
||||
}
|
||||
found["integrity_check"] = conn.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
found["foreign_key_check"] = len(
|
||||
conn.execute("PRAGMA foreign_key_check").fetchall()
|
||||
)
|
||||
return found
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _print_counts(before, after, out):
|
||||
out.write(f"{'':24}{'before':>12}{'after':>12}\n")
|
||||
for key in before:
|
||||
out.write(f"{key:24}{before[key]!s:>12}{after[key]!s:>12}\n")
|
||||
|
||||
|
||||
def _summary(plan):
|
||||
actions = [item["action"] for item in plan["rows"]]
|
||||
return " ".join(
|
||||
f"{name}: {actions.count(name)}" for name in ("relink", "keep", "skip")
|
||||
)
|
||||
|
||||
|
||||
def _cell(text, limit=EXCERPT):
|
||||
"""One table cell: whitespace folded, cut to ``limit`` (None: whole)."""
|
||||
folded = " ".join(str(text).split())
|
||||
return (folded if limit is None else folded[:limit]).replace("|", "\\|")
|
||||
|
||||
|
||||
def _relative(path, root):
|
||||
try:
|
||||
return os.path.relpath(path, root)
|
||||
except ValueError: # another drive on Windows
|
||||
return path
|
||||
|
||||
|
||||
def markdown(plan, prompt_texts):
|
||||
"""The maintainer's list of the plan."""
|
||||
root = plan["output_dir"]
|
||||
rel = lambda path: _relative(path, root) # noqa: E731
|
||||
rows = plan["rows"]
|
||||
lines = [
|
||||
"# #247 repair plan (dry run)",
|
||||
"",
|
||||
f"Created {plan['created']}; output folder `{root}`. {_summary(plan)}.",
|
||||
"",
|
||||
f"## Relink ({sum(i['action'] == 'relink' for i in rows)})",
|
||||
"",
|
||||
"| row | file | linked prompt | linked text | target | target hash "
|
||||
"| target text |",
|
||||
"|---:|---|---:|---|---|---|---|",
|
||||
]
|
||||
for item in (i for i in rows if i["action"] == "relink"):
|
||||
lines.append(
|
||||
f"| {item['row_id']} | {_cell(rel(item['file']), None)} | "
|
||||
f"{item['current_prompt_id']} | "
|
||||
f"{_cell(prompt_texts.get(item['current_prompt_id'], ''))} | "
|
||||
f"{item['target_prompt_id']} | `{item['target_hash'][:12]}` | "
|
||||
f"{_cell(item['target_text'])} |"
|
||||
)
|
||||
lines += ["", f"## Keep ({sum(i['action'] == 'keep' for i in rows)})", ""]
|
||||
lines += ["| row | file | prompt |", "|---:|---|---:|"]
|
||||
for item in (i for i in rows if i["action"] == "keep"):
|
||||
lines.append(
|
||||
f"| {item['row_id']} | {_cell(rel(item['file']), None)} | "
|
||||
f"{item['current_prompt_id']} |"
|
||||
)
|
||||
lines += ["", f"## Skip ({sum(i['action'] == 'skip' for i in rows)})", ""]
|
||||
lines += ["| row | file | prompt | reason |", "|---:|---|---:|---|"]
|
||||
for item in (i for i in rows if i["action"] == "skip"):
|
||||
reason = item["reason"] + (
|
||||
f" ({item['decision']})" if item.get("decision") else ""
|
||||
)
|
||||
lines.append(
|
||||
f"| {item['row_id']} | {_cell(rel(item['file']), None)} | "
|
||||
f"{item['current_prompt_id']} | {reason} |"
|
||||
)
|
||||
choice = "ON (--delete-orphans)" if plan["delete_orphans"] else "OFF"
|
||||
lines += [
|
||||
"",
|
||||
f"## Prompts that would lose every link ({len(plan['orphans'])})",
|
||||
"",
|
||||
f"Deleting the safe ones: {choice}.",
|
||||
"",
|
||||
"| id | text | images before | blockers | safe to delete |",
|
||||
"|---:|---|---:|---|---|",
|
||||
]
|
||||
for orphan in plan["orphans"]:
|
||||
lines.append(
|
||||
f"| {orphan['id']} | {_cell(orphan['text_excerpt'])} | "
|
||||
f"{orphan['images_before']} | {', '.join(orphan['blockers']) or 'none'} | "
|
||||
f"{'yes' if orphan['safe'] else 'no'} |"
|
||||
)
|
||||
simulated = plan.get("simulated")
|
||||
if simulated:
|
||||
lines += ["", "## Rehearsed on the dry run's copy", ""]
|
||||
lines += ["| table | before | after |", "|---|---:|---:|"]
|
||||
for key, value in simulated["before"].items():
|
||||
lines.append(f"| {key} | {value} | {simulated['after'][key]} |")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _texts(db):
|
||||
return dict(
|
||||
db.model.get_connection().execute(
|
||||
"SELECT id, text FROM prompts WHERE id IN (SELECT prompt_id "
|
||||
"FROM generated_images)"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _rehearse(source, scratch, name, plan):
|
||||
"""``apply_plan`` on a fresh backup of ``source``: (counts, result, audit)."""
|
||||
copy = os.path.join(scratch, name)
|
||||
backup_database(source, copy)
|
||||
db = PromptDatabase(copy)
|
||||
try:
|
||||
before = counts(copy)
|
||||
result, audit = apply_plan(db, plan)
|
||||
finally:
|
||||
db.close_all()
|
||||
return {"before": before, "after": counts(copy), "result": result}, audit
|
||||
|
||||
|
||||
def dry_run(args, out):
|
||||
with tempfile.TemporaryDirectory() as scratch:
|
||||
copy = os.path.join(scratch, "judge.db")
|
||||
backup_database(args.database, copy)
|
||||
db = PromptDatabase(copy)
|
||||
try:
|
||||
plan = build_plan(db, os.path.abspath(args.output_dir), args.database)
|
||||
texts = _texts(db)
|
||||
finally:
|
||||
db.close_all()
|
||||
simulated, audit = _rehearse(args.database, scratch, "relinks.db", plan)
|
||||
plan.update(orphans=audit, delete_orphans=bool(args.delete_orphans))
|
||||
if plan["delete_orphans"]:
|
||||
# The plan as the apply will write it, deletes included
|
||||
simulated, _audit = _rehearse(args.database, scratch, "deletes.db", plan)
|
||||
plan["simulated"] = simulated
|
||||
plan_path = Path(args.plan)
|
||||
plan_path.write_text(
|
||||
json.dumps(plan, indent=1, ensure_ascii=False) + "\n", encoding="utf-8"
|
||||
)
|
||||
list_path = plan_path.with_name(plan_path.stem + ".list.md")
|
||||
list_path.write_text(markdown(plan, texts), encoding="utf-8")
|
||||
out.write(f"{_summary(plan)}\n")
|
||||
out.write(
|
||||
f"Prompts that would lose every link: {len(audit)} "
|
||||
f"({sum(o['safe'] for o in audit)} safe to delete; deleting "
|
||||
f"{'ON' if plan['delete_orphans'] else 'OFF'})\n"
|
||||
)
|
||||
out.write(f"Plan: {plan_path}\nList: {list_path}\n")
|
||||
out.write("Dry run: nothing was changed.\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _is_live(database, comfyui_dir):
|
||||
live = os.path.join(comfyui_dir, *LIVE_LIBRARY)
|
||||
return library_identity(database) == library_identity(live)
|
||||
|
||||
|
||||
def _stamp():
|
||||
return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def schema_of(path):
|
||||
"""``sqlite_master`` and ``user_version`` from a read-only connection."""
|
||||
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
master = conn.execute(
|
||||
"SELECT type, name, tbl_name, sql FROM sqlite_master ORDER BY type, name"
|
||||
).fetchall()
|
||||
return master, conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def running_reasons(args, probe, handles):
|
||||
"""Why writing must not start now (empty: go ahead).
|
||||
|
||||
Another process holding the library open always counts, also in a
|
||||
rehearsal; ComfyUI's processes and port count unless rehearsing.
|
||||
"""
|
||||
held = [
|
||||
reason
|
||||
for reason in handles(args.database)
|
||||
if not (reason == NO_PROC and args.i_stopped_comfyui)
|
||||
]
|
||||
return held + ([] if args.rehearsal else probe(args.comfyui_dir, args.port))
|
||||
|
||||
|
||||
def _preflight(args, plan, probe, handles):
|
||||
"""The refusals before any backup or write, as a message (None: go on)."""
|
||||
if plan.get("version") != PLAN_VERSION:
|
||||
return "not a plan of this tool"
|
||||
if args.rehearsal and _is_live(args.database, args.comfyui_dir):
|
||||
return f"--rehearsal is for a copy; this is the library of {args.comfyui_dir}"
|
||||
if plan.get("source_db") != library_identity(args.database):
|
||||
return f"this plan was made for {plan.get('source_db')}"
|
||||
if not os.path.isfile(os.path.join(args.comfyui_dir, "main.py")):
|
||||
return f"no main.py in {args.comfyui_dir}: not a ComfyUI folder"
|
||||
reasons = running_reasons(args, probe, handles)
|
||||
if reasons:
|
||||
return "ComfyUI may be running: " + "; ".join(reasons)
|
||||
return None
|
||||
|
||||
|
||||
def _backup(args, before):
|
||||
"""The verified backup's path; raises ``Refused``."""
|
||||
folder = args.backup_dir or os.path.dirname(os.path.abspath(args.database))
|
||||
name = f"{os.path.basename(args.database)}.pre-247-repair_{_stamp()}"
|
||||
backup = os.path.join(folder, name)
|
||||
if os.path.exists(backup):
|
||||
raise Refused(f"the backup {backup} already exists")
|
||||
backup_database(args.database, backup)
|
||||
saved = counts(backup, readonly=False)
|
||||
if saved != before or saved["integrity_check"] != "ok":
|
||||
raise Refused(f"the backup {backup} does not match the library")
|
||||
return backup
|
||||
|
||||
|
||||
def _open_unchanged(path):
|
||||
"""``PromptDatabase(path)``; raises ``Refused`` when opening it changed
|
||||
its schema (a migration of a newer checkout, committed on open)."""
|
||||
schema = schema_of(path)
|
||||
db = PromptDatabase(path)
|
||||
db.model.get_connection()
|
||||
if schema_of(path) != schema:
|
||||
db.close_all()
|
||||
raise Refused(
|
||||
"opening the library with this checkout changed its schema (that "
|
||||
"change is committed); run the tool from a checkout at the live "
|
||||
"code version"
|
||||
)
|
||||
return db
|
||||
|
||||
|
||||
def apply(args, out, probe, handles):
|
||||
with open(args.apply, encoding="utf-8") as stream:
|
||||
plan = json.load(stream)
|
||||
refusal = _preflight(args, plan, probe, handles)
|
||||
if refusal:
|
||||
out.write(f"REFUSED: {refusal}\n")
|
||||
return 2
|
||||
if args.rehearsal:
|
||||
out.write("(rehearsal on a copy: ComfyUI's process and port not checked)\n")
|
||||
before = counts(args.database)
|
||||
try:
|
||||
backup = _backup(args, before)
|
||||
except Refused as error:
|
||||
out.write(f"REFUSED: {error}\n")
|
||||
return 2
|
||||
out.write(f"Backup: {backup}\n")
|
||||
|
||||
def still_stopped():
|
||||
reasons = running_reasons(args, probe, handles)
|
||||
if reasons:
|
||||
raise Refused("ComfyUI may be running: " + "; ".join(reasons))
|
||||
|
||||
try:
|
||||
db = _open_unchanged(args.database)
|
||||
except Refused as error:
|
||||
out.write(f"REFUSED: {error}. Backup: {backup}\n")
|
||||
return 2
|
||||
try:
|
||||
result, _audit = apply_plan(db, plan, before_write=still_stopped)
|
||||
except Exception as error:
|
||||
out.write(
|
||||
f"REFUSED (rolled back, nothing changed): {error}. Backup: {backup}\n"
|
||||
)
|
||||
return 2
|
||||
finally:
|
||||
db.close_all()
|
||||
after = counts(args.database)
|
||||
_print_counts(before, after, out)
|
||||
out.write(f"Applied: {json.dumps(result, sort_keys=True)}\n")
|
||||
if after["integrity_check"] != "ok" or after["foreign_key_check"]:
|
||||
out.write(f"WARNING: integrity or foreign-key check failed; restore {backup}\n")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None, out=sys.stdout, probe=comfyui_running, handles=open_by_others):
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||
parser.add_argument("database", help="the library database (prompts.db)")
|
||||
parser.add_argument("--output-dir", help="ComfyUI's output folder (dry run)")
|
||||
parser.add_argument(
|
||||
"--plan", default="repair_foreign_links_plan.json", help="plan to write"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete-orphans",
|
||||
action="store_true",
|
||||
help="record in the plan that the safe orphan prompts are deleted",
|
||||
)
|
||||
parser.add_argument("--apply", metavar="PLAN", help="write this reviewed plan")
|
||||
parser.add_argument("--comfyui-dir", help="the ComfyUI folder (apply)")
|
||||
parser.add_argument("--backup-dir", help="where the backup goes (apply)")
|
||||
parser.add_argument("--port", type=int, default=COMFY_PORT)
|
||||
parser.add_argument(
|
||||
"--rehearsal",
|
||||
action="store_true",
|
||||
help="apply to a copy while ComfyUI runs (never its own library)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--i-stopped-comfyui",
|
||||
action="store_true",
|
||||
help="without /proc: say ComfyUI is stopped (the port is still checked)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if not os.path.isfile(args.database):
|
||||
parser.error(f"no database at {args.database}")
|
||||
if args.apply:
|
||||
if not args.comfyui_dir:
|
||||
parser.error("--apply needs --comfyui-dir (the running check)")
|
||||
return apply(args, out, probe, handles)
|
||||
if not args.output_dir:
|
||||
parser.error("the dry run needs --output-dir")
|
||||
if Path(args.plan).suffix.lower() == ".md":
|
||||
parser.error("--plan is the JSON plan; the list is written beside it")
|
||||
return dry_run(args, out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -32,8 +32,8 @@ export function imageKey(image) {
|
||||
return `${image.root_index}:${image.path}`;
|
||||
}
|
||||
|
||||
/** The workspace listing's sorts: the concept's Recently added, Name A–Z and Highest rated, plus Oldest and Z–A (D14). */
|
||||
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc', 'rating']);
|
||||
/** The workspace listing's sorts: the concept's Recently added and Name A–Z, plus Oldest and Z–A (D14; no rating, D153). */
|
||||
export const IMAGE_SORTS = Object.freeze(['newest', 'oldest', 'name', 'name-desc']);
|
||||
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
// The largest page a refresh asks for (the listing clamps at 500).
|
||||
@@ -315,7 +315,7 @@ const startOfDay = date => new Date(date.getFullYear(), date.getMonth(), date.ge
|
||||
|
||||
/**
|
||||
* D17: the caption's right-hand date, "<Today|Yesterday|weekday> · <Month D>" of the newest result under Newest first,
|
||||
* with ", YYYY" outside the current year; '' under Oldest first and the name or rating sorts.
|
||||
* with ", YYYY" outside the current year; '' under Oldest first and the name sorts.
|
||||
*/
|
||||
export function captionDate(items, sort, now = new Date()) {
|
||||
if (sort !== 'newest') return '';
|
||||
|
||||
@@ -61,8 +61,9 @@ function selectOf(id, options, value) {
|
||||
return select;
|
||||
}
|
||||
|
||||
// The concept's three sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3).
|
||||
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'], ['rating', 'Highest rated'],
|
||||
// The concept's sorts first, then the production ones it lacks (D14); values are the listing's sorts (B3). D153 (#447)
|
||||
// drops the concept's "Highest rated".
|
||||
const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', 'Name A–Z'],
|
||||
['oldest', 'Oldest first'], ['name-desc', 'Name Z–A']]);
|
||||
const MEDIA_OPTIONS = Object.freeze([['all', 'All media'], ['image', 'Images'], ['video', 'Videos']]);
|
||||
const RETRY_RESCAN = 'Rescan from Import to retry.';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The concept's "Inspect a file" page (docs/concepts/workspace/app.js renderMetadata 740–748, selection 1715–1729):
|
||||
// one local file, chosen or dropped, previews inline; the heading's one action opens the shared viewer with the full
|
||||
// inspector on a record read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its
|
||||
// one local file, chosen or dropped, previews inline and (D155, #448) opens the shared viewer with its details showing
|
||||
// at once; the heading's one action opens it again. The record is read entirely in this browser. Nothing is uploaded, imported or stored. The selection, its
|
||||
// blob: URL and its record belong to one local session (local-session.mjs) that ends with this page.
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
@@ -17,7 +17,8 @@ export const INSPECT_COPY = Object.freeze({
|
||||
heading: 'Inspect without importing',
|
||||
intro: 'Read embedded metadata in this browser. The file is not uploaded or added to your library.',
|
||||
chooser: 'Choose an image or video for a local preview',
|
||||
note: 'Your file stays in this browser. Its metadata opens with “Open file metadata”.',
|
||||
// D155 (#448): choosing a file opens its metadata (D61's note named the heading action).
|
||||
note: 'Your file stays in this browser. Choosing it opens its metadata.',
|
||||
toast: 'Local preview only. No file uploaded; metadata is available above.',
|
||||
});
|
||||
// D62: the concept's image list plus the videos the local readers support (AVI/WMV preview only).
|
||||
@@ -38,8 +39,11 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// The status shown for the selection; AVI/WMV's note and an empty status keep the toast's "metadata is available".
|
||||
const TOASTABLE = new Set(['', LOCAL_SESSION_MESSAGES.containerOnly]);
|
||||
// D155 (#448): a selection with no status, or one without a preview URL (its copy points at the viewer), opens the viewer
|
||||
// at once. AVI/WMV's note keeps the toast instead (the viewer could not play it and reads no metadata), and an
|
||||
// unreadable file stays on the page with its alert; the heading action opens either.
|
||||
const AUTO_OPEN = new Set(['', LOCAL_SESSION_MESSAGES.previewFailed]);
|
||||
const TOASTABLE = new Set([LOCAL_SESSION_MESSAGES.containerOnly]);
|
||||
// How long a replaced video still starting may stay in place before it is released anyway.
|
||||
const STARTING_VIDEO_MS = 15000;
|
||||
|
||||
@@ -131,9 +135,10 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
};
|
||||
const session = createLocalSession({ parentScope: sessionScope, pageTarget: null, revokeObjectURL });
|
||||
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: session.controller, sender,
|
||||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); } });
|
||||
adapter: localFileAdapter, onRestoreFocus: () => { if (open.isConnected) open.focus({ preventScroll: true }); },
|
||||
onClose: () => { viewerKey = null; } });
|
||||
|
||||
let media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
|
||||
let viewerKey = null, media = null, mediaUrl = null, statusLine = null, rejectionLine = null, rejectionTimer = 0, announced = null, chosen = null;
|
||||
function statusElement(message) {
|
||||
// /* D63 */ one status line for the states the mock never shows.
|
||||
const line = element('p', 'small spaced-16', message);
|
||||
@@ -202,14 +207,25 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
else preview.prepend(node);
|
||||
}
|
||||
scope.cleanup(session.subscribe(state => {
|
||||
// D155: a new selection can start while the viewer shows the previous one; it closes first, so no element holds
|
||||
// the old URL when the session revokes it (the new file's viewer opens once it is ready).
|
||||
if (viewerKey !== null && (state.phase !== 'ready' || session.entry?.key !== viewerKey)) { viewer.close(); viewerKey = null; }
|
||||
showMedia(state);
|
||||
showStatus(state.message);
|
||||
const entry = session.entry;
|
||||
setAvailable(state.phase === 'ready' && !!entry);
|
||||
if (state.phase === 'ready' && entry && announced !== entry.key) {
|
||||
announced = entry.key;
|
||||
// The session selects the record right after it publishes `ready`, so the viewer opens after that, and only
|
||||
// while this selection is still the current one and no other dialog is open (the viewer is replaced). D62:
|
||||
// a video opens it once the inline preview has settled, so a replacement while it starts never tears down a
|
||||
// second starting video in the viewer (that deadlocked WebKit).
|
||||
if (AUTO_OPEN.has(state.message)) whenSettled(media, () => {
|
||||
if (scope.signal.aborted || session.entry !== entry || session.state.phase !== 'ready' || document.querySelector('dialog[open]:not(#viewer-dialog)')) return;
|
||||
openViewer(input);
|
||||
});
|
||||
// An unreadable or failed preview says so in its alert; the toast never claims metadata is available.
|
||||
if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
|
||||
else if (TOASTABLE.has(state.message)) showWorkspaceToast(INSPECT_COPY.toast);
|
||||
}
|
||||
}));
|
||||
// A replacement or page leave revokes the URL after this subscriber dropped the media; a dispose closes the
|
||||
@@ -261,10 +277,25 @@ export function mountInspect(main, { parentScope, api, sender }) {
|
||||
});
|
||||
// A drop that misses the area never navigates away from the page while it is mounted.
|
||||
for (const type of ['dragover', 'drop']) scope.listen(window, type, event => event.preventDefault());
|
||||
scope.listen(open, 'click', () => {
|
||||
/** Runs `run` after this task, or once `preview`'s video stops starting (metadata, an error, or the D62 limit). */
|
||||
function whenSettled(preview, run) {
|
||||
if (!preview || !isStarting(preview.node)) { queueMicrotask(run); return; }
|
||||
// `run` checks that the page and the selection are still current, so the page's end may call `done` too.
|
||||
const settled = new AbortController();
|
||||
const done = () => { settled.abort(); clearTimeout(timer); run(); };
|
||||
const timer = setTimeout(done, STARTING_VIDEO_MS);
|
||||
for (const type of ['loadedmetadata', 'error']) preview.node.addEventListener(type, done, { once: true, signal: settled.signal });
|
||||
scope.cleanup(done);
|
||||
}
|
||||
/** The viewer on the current selection, details showing (D155 amends D97); focus returns to `trigger` on close. */
|
||||
function openViewer(trigger) {
|
||||
const entry = session.entry;
|
||||
if (open.getAttribute('aria-disabled') === 'true' || !entry || session.state.phase !== 'ready') return;
|
||||
viewer.open({ items: [entry], selectedKey: entry.key, trigger: open });
|
||||
if (!entry || session.state.phase !== 'ready') return;
|
||||
viewer.open({ items: [entry], selectedKey: entry.key, trigger, details: 'shown' });
|
||||
viewerKey = entry.key;
|
||||
}
|
||||
scope.listen(open, 'click', () => {
|
||||
if (open.getAttribute('aria-disabled') !== 'true') openViewer(open);
|
||||
});
|
||||
/** Settles when no video on the page is still starting; `null` when none is. */
|
||||
function leaveGuard() {
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
*/
|
||||
const MODES = new Set(['new', 'metadata', 'revision', 'negative']);
|
||||
const KINDS = new Set(['metadata', 'create', 'add-negative', 'archive-pair', 'restore-pair']);
|
||||
const FIELDS = ['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes', 'rating'];
|
||||
// D153 (#447): the workspace edits no 1–5 rating; a stored one is left exactly as it is.
|
||||
export const EDITOR_FIELDS = Object.freeze(['title', 'positive_text', 'negative_text', 'category', 'tags', 'notes']);
|
||||
const FIELDS = EDITOR_FIELDS;
|
||||
const RECOVERY = {'metadata-conflict': 'conflict', 'storage-changed': 'storage-changed'};
|
||||
// A proxy timeout or bad gateway cannot tell whether the server committed the write.
|
||||
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
||||
@@ -106,27 +108,20 @@ export function fallbackTitle(text) {return text.split('\n')[0].trim();}
|
||||
export function parseTags(value) {return [...new Set(value.split(',').map(tag => tag.trim()).filter(Boolean))];}
|
||||
const sameTags = (a, b) => a.length === b.length && new Set([...a, ...b]).size === a.length;
|
||||
const stored = value => (value ?? '').trim();
|
||||
function normalizeRating(value) {
|
||||
if (value === null || value === '') return null;
|
||||
const number = typeof value === 'string' && /^[1-5]$/.test(value) ? Number(value) : value;
|
||||
if (!rating(number)) throw new TypeError('Rating must be 1 to 5 or null');
|
||||
return number;
|
||||
}
|
||||
|
||||
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: '', rating: null});
|
||||
const blankDraft = () => ({title: '', positive_text: '', negative_text: '', category: '', tags: '', notes: ''});
|
||||
function seedDraft(mode, {owner, pair}) {
|
||||
if (!owner) return blankDraft();
|
||||
if (mode === 'negative') return {...blankDraft(), positive_text: owner.text};
|
||||
return {title: stored(owner.title) ? owner.title : fallbackTitle(owner.text), positive_text: owner.text,
|
||||
negative_text: pair ? pair.negative_text : '', category: owner.category ?? '', tags: owner.tags.join(', '),
|
||||
notes: owner.notes ?? '', rating: owner.rating};
|
||||
notes: owner.notes ?? ''};
|
||||
}
|
||||
/** Keep fields the user changed from the previous seed; the rest follow the new seed. */
|
||||
function overlay(next, draft, previous) {
|
||||
return Object.fromEntries(FIELDS.map(name => [name, draft[name] !== previous[name] ? draft[name] : next[name]]));
|
||||
}
|
||||
function baselineOf(owner) {
|
||||
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], rating: owner.rating, notes: owner.notes};
|
||||
return owner && {title: owner.title, category: owner.category, tags: [...owner.tags], notes: owner.notes};
|
||||
}
|
||||
|
||||
/** Only changed fields; an untouched fallback title is not a title edit. */
|
||||
@@ -136,19 +131,18 @@ function metadataChanges(draft, seed, baseline) {
|
||||
if (draft.title !== seed.title && title !== stored(baseline.title)) changes.title = title;
|
||||
if (category !== stored(baseline.category)) changes.category = category;
|
||||
if (!sameTags(tags, baseline.tags)) changes.tags = tags;
|
||||
if (draft.rating !== baseline.rating) changes.rating = draft.rating;
|
||||
if (draft.notes !== (baseline.notes ?? '')) changes.notes = draft.notes;
|
||||
return changes;
|
||||
}
|
||||
function createMetadata(draft) {
|
||||
return {title: draft.title.trim(), category: draft.category.trim() || UNCATEGORIZED, tags: parseTags(draft.tags),
|
||||
rating: draft.rating, notes: draft.notes};
|
||||
notes: draft.notes};
|
||||
}
|
||||
// A blank category is stored as NULL; "Uncategorized" is only the UI's label for it.
|
||||
const categoryKey = value => (stored(value) === UNCATEGORIZED ? '' : stored(value));
|
||||
function sameMetadata(sent, owner) {
|
||||
return sent.title === stored(owner.title) && categoryKey(sent.category) === categoryKey(owner.category) && sameTags(sent.tags, owner.tags) &&
|
||||
sent.rating === owner.rating && sent.notes === (owner.notes ?? '');
|
||||
sent.notes === (owner.notes ?? '');
|
||||
}
|
||||
function draftProblem(mode, draft) {
|
||||
if (mode !== 'negative' && !draft.title.trim()) return Object.assign(new Error('Title is required'), {code: 'invalid-draft'});
|
||||
@@ -328,9 +322,8 @@ export function createLibraryEditorController({api, mode, positiveId = null, pai
|
||||
},
|
||||
setField(name, value) {
|
||||
if (!FIELDS.includes(name)) throw new TypeError(`Unknown editor field: ${name}`);
|
||||
const next = name === 'rating' ? normalizeRating(value) : value;
|
||||
if (name !== 'rating' && typeof next !== 'string') throw new TypeError(`${name} must be a string`);
|
||||
emit({draft: {...state.draft, [name]: next}});
|
||||
if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
|
||||
emit({draft: {...state.draft, [name]: value}});
|
||||
},
|
||||
async submit() {
|
||||
if (disposed || !SUBMITTABLE.has(state.phase)) return;
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
* module only renders `controller.state` and forwards user actions.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { createLibraryEditorController } from './library-editor-state.mjs';
|
||||
import { createLibraryEditorController, EDITOR_FIELDS } from './library-editor-state.mjs';
|
||||
|
||||
const TITLES = {new: 'New prompt', metadata: 'Edit recipe metadata', revision: 'Create text revision', negative: 'Add a negative variant'};
|
||||
const RATINGS = [['5', '5 / 5'], ['4', '4 / 5'], ['3', '3 / 5'], ['2', '2 / 5'], ['1', '1 / 5'], ['0', 'Unrated']];
|
||||
const FIELD_IDS = {title: 'recipe-title', positive_text: 'recipe-positive', negative_text: 'recipe-negative', category: 'recipe-category',
|
||||
tags: 'recipe-tags', rating: 'recipe-rating', notes: 'recipe-notes'};
|
||||
tags: 'recipe-tags', notes: 'recipe-notes'};
|
||||
const SUBMITTABLE = new Set(['ready', 'write-error']);
|
||||
const NEGATIVE_HELP = 'Leave empty for an explicitly empty negative. The positive stays unchanged.';
|
||||
const NO_PAIR = 'Archive needs a selected saved negative variant.';
|
||||
@@ -218,9 +217,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
category.setAttribute('list', 'categories');
|
||||
tagInput.setAttribute('list', 'tag-options');
|
||||
columns.append(field('Category', category, FIELD_IDS.category), field('Tags, separated by commas', tagInput, FIELD_IDS.tags));
|
||||
const rating = controls.rating = el('select');
|
||||
for (const [value, text] of RATINGS) rating.add(new Option(text, value));
|
||||
form.append(columns, categories, tags, field('Rating', rating, FIELD_IDS.rating), field('Notes', textarea('notes'), FIELD_IDS.notes));
|
||||
// D153 (#447): no Rating field; the concept's `#recipe-rating` select is not drawn.
|
||||
form.append(columns, categories, tags, field('Notes', textarea('notes'), FIELD_IDS.notes));
|
||||
} else form.append(el('p', NEGATIVE_HELP, 'small'));
|
||||
const line = statusLine(), recovery = new Map();
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
@@ -230,9 +228,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
form.append(line.note, actions);
|
||||
|
||||
for (const [name, node] of Object.entries(controls)) {
|
||||
node.addEventListener(node.tagName === 'SELECT' ? 'change' : 'input', () => {
|
||||
controller.setField(name, name === 'rating' && node.value === '0' ? null : node.value);
|
||||
}, {signal: views.signal});
|
||||
node.addEventListener('input', () => controller.setField(name, node.value), {signal: views.signal});
|
||||
}
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
@@ -246,7 +242,7 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
let categoryKey = null, refocus = null;
|
||||
function render(state) {
|
||||
for (const [name, node] of Object.entries(controls)) {
|
||||
const value = name === 'rating' ? String(state.draft.rating ?? 0) : state.draft[name];
|
||||
const value = state.draft[name];
|
||||
if (node.value !== value) node.value = value;
|
||||
}
|
||||
const key = state.categories.join('\n');
|
||||
@@ -274,7 +270,8 @@ export function createLibraryEditor({api, parentScope, onCommitted = () => {}, o
|
||||
|
||||
body.replaceChildren(form);
|
||||
return {controller, views, render, start() {
|
||||
if (draft) for (const [name, value] of Object.entries(draft)) controller.setField(name, value);
|
||||
// A kept draft carries only the editor's own fields (D153: never a rating).
|
||||
if (draft) for (const [name, value] of Object.entries(draft)) if (EDITOR_FIELDS.includes(name)) controller.setField(name, value);
|
||||
render(controller.state);
|
||||
controller.initialize();
|
||||
}};
|
||||
|
||||
@@ -60,9 +60,3 @@ export function usedLabel(lastUsedAt, now = new Date()) {
|
||||
const year = used.getFullYear() === now.getFullYear() ? '' : `, ${used.getFullYear()}`;
|
||||
return `Used ${MONTHS[used.getMonth()]} ${used.getDate()}${year}`;
|
||||
}
|
||||
|
||||
/** The concept's stars (`★` × rating) with their visually hidden reading; an unrated parent shows neither. */
|
||||
export function ratingLabel(rating) {
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) return {stars: '', hidden: ''};
|
||||
return {stars: '★'.repeat(rating), hidden: `Rated ${rating} out of 5`};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ function parameters(filters,limit) {
|
||||
if(filters.tags!==undefined && (!Array.isArray(filters.tags) || filters.tags.some(tag=>typeof tag!=='string'))) throw new TypeError('Invalid library tags');
|
||||
for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim());
|
||||
const sort=filters.sort || 'newest';
|
||||
if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
if(!['newest','oldest','name'].includes(sort)) throw new TypeError('Invalid library sort');
|
||||
query.set('sort',sort);return query;
|
||||
}
|
||||
// `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants").
|
||||
|
||||
@@ -6,7 +6,7 @@ import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './libr
|
||||
import {promptRoles} from './prompt-blocks.mjs';
|
||||
import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs';
|
||||
import {createLibraryEditorController} from './library-editor-state.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows,usedLabel,ratingLabel} from './library-layout.mjs';
|
||||
import {layoutLibraryRows,visibleLibraryRows,usedLabel} from './library-layout.mjs';
|
||||
import {createWorkspaceIcon} from './icons.mjs';
|
||||
import {mountRecordedHistory} from './history.mjs';
|
||||
import {countOf} from './plural.mjs';
|
||||
@@ -29,8 +29,8 @@ const selectedPairs=new Map();
|
||||
// Parent metadata a reuse did not apply, kept for an explicit Edit metadata action.
|
||||
const metadataDrafts=new Map();
|
||||
const archivedVisible=new Map();
|
||||
// The concept's three sorts first, then the Library's appended Oldest first (D14).
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]);
|
||||
// The concept's sorts first, then the Library's appended Oldest first (D14); D153 (#447) drops "Highest rated".
|
||||
const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]);
|
||||
// A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437).
|
||||
const CARD_CONTROLS='button, input[type="checkbox"]';
|
||||
const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';
|
||||
@@ -111,7 +111,7 @@ function outcomeMessage(result,outcome) {
|
||||
if(kind!=='create' && kind!=='add-negative') return null;
|
||||
if(receipt.positive_created) return 'Saved.';
|
||||
if(receipt.pair_created) return kind==='add-negative'?'Negative saved without adding another positive or gallery.'
|
||||
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags, rating and notes you entered were not applied.'
|
||||
:outcome?.metadataNotApplied?'Saved as a new negative variant of an existing prompt. The title, category, tags and notes you entered were not applied.'
|
||||
:'Saved as a new negative variant of an existing prompt.';
|
||||
if(receipt.pair.archived) return 'This combination is an archived negative variant. It stays archived; restore it from Show archived in Negative variants.';
|
||||
return 'This prompt pair already exists. Showing the saved version.';
|
||||
@@ -122,7 +122,7 @@ function takeSavedSelection(positiveId) {
|
||||
}
|
||||
function rememberUnappliedMetadata(result,{outcome,draft}={}) {
|
||||
if(result.commit.kind!=='create' || !outcome?.metadataNotApplied || !draft) return;
|
||||
const {title:name,category,tags,rating,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,rating,notes});
|
||||
const {title:name,category,tags,notes}=draft;metadataDrafts.set(result.detail.positive.id,{title:name,category,tags,notes});
|
||||
}
|
||||
/** New prompt, from Library or Images: a saved prompt always opens its own history, as in the concept. */
|
||||
function createNewPromptEditor(api,scope,main) {
|
||||
@@ -159,6 +159,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
// An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again.
|
||||
const restorable=visit?.state?visit:null;
|
||||
const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved};
|
||||
// D153: a sort the shelf no longer offers (an older "Highest rated" visit) reads as Recently added.
|
||||
if(!LIBRARY_SORTS.some(([value])=>value===filters.sort)) filters.sort='newest';
|
||||
const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library');
|
||||
// The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15).
|
||||
// Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY.
|
||||
@@ -220,7 +222,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
if(restorable?.layout?.inlineSize===inlineSize && restorable.layout.columns===columnCount) for(const [id,height] of restorable.layout.heights) heights.set(id,height);
|
||||
const hiddenSave=restorable?null:savedParentId;savedParentId=null;
|
||||
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
|
||||
/** The concept's prompt card (app.js renderLibrary a586) with real counts, last use and rating (#249 D23). */
|
||||
/** The concept's prompt card (app.js renderLibrary a586) with real counts and last use (#249 D23; no rating, D153). */
|
||||
function cardFor(item,index) {
|
||||
const article=el('article',undefined,'prompt-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem');
|
||||
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
|
||||
@@ -246,10 +248,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const vocabulary=el('div',undefined,'tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'chip'));
|
||||
content.append(heading,prompts,meta,vocabulary);
|
||||
const footer=el('div',undefined,'card-footer');
|
||||
const {stars,hidden}=ratingLabel(item.rating), used=usedLabel(item.last_used_at);
|
||||
const facts=el('span');
|
||||
if(stars) {const shown=el('span',stars);shown.setAttribute('aria-hidden','true');facts.append(shown,el('span',hidden,'ca-visually-hidden'),` \u00b7 ${used}`);}
|
||||
else facts.textContent=used;
|
||||
// D153 (#447): the footer reads only the last use; the concept's rating stars are not drawn.
|
||||
const facts=el('span',usedLabel(item.last_used_at));
|
||||
const actions=el('div',undefined,'row-actions');
|
||||
const send=createButton('','send icon-btn');send.setAttribute('aria-label',`Send positive prompt to ComfyUI: ${name}`);send.append(createWorkspaceIcon('send'));send.dataset.sendPositive='';
|
||||
const explore=createButton('');explore.append(createWorkspaceIcon('branch'),' Explore history');explore.dataset.history='';
|
||||
|
||||
@@ -39,7 +39,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
function open(options) {
|
||||
if (scope.signal.aborted) return;
|
||||
active?.close(false);
|
||||
const {items,selectedKey,trigger = document.activeElement} = options;
|
||||
const {items,selectedKey,trigger = document.activeElement,details:detailsMode} = options;
|
||||
let mounted = false, initial, entry, currentKey, viewer = null, withoutViewer = false, mediaScope = null, closed = false;
|
||||
let originalDownload = null, inspectionState = inspectionController.state, collectionChanges = null;
|
||||
const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => {
|
||||
@@ -79,11 +79,12 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
|
||||
controls.append(download);
|
||||
const next = button('Next media','Next →',() => session.navigate(1));
|
||||
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle.
|
||||
const collapsed = displayPreferences()?.sidebarCollapsedByDefault === true;
|
||||
// D97: "Collapse viewer details by default" opens the viewer with its details hidden behind one toggle. D155
|
||||
// (#448): `details: 'shown'` (Inspect a file) keeps the toggle but opens with the details showing.
|
||||
const collapsible = displayPreferences()?.sidebarCollapsedByDefault === true, collapsed = collapsible && detailsMode !== 'shown';
|
||||
let detailsToggle = null;
|
||||
if (collapsed) {
|
||||
detailsToggle = createButton('Show details');detailsToggle.setAttribute('aria-expanded','false');
|
||||
if (collapsible) {
|
||||
detailsToggle = createButton(collapsed ? 'Show details' : 'Hide details');detailsToggle.setAttribute('aria-expanded',String(!collapsed));
|
||||
detailsToggle.setAttribute('aria-controls','viewer-details');controls.append(detailsToggle);
|
||||
}
|
||||
const position = element('p','media-position muted');position.id = `media-viewer-position-${++viewerId}`;
|
||||
@@ -93,7 +94,7 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
|
||||
controls.append(position,reason,slideshowReason);left.append(stage,controls);
|
||||
const details = element('aside');details.id = 'viewer-details';details.setAttribute('aria-label','Image metadata');
|
||||
if (detailsToggle) {
|
||||
details.hidden = true;
|
||||
details.hidden = collapsed;
|
||||
local.listen(detailsToggle,'click',() => {
|
||||
details.hidden = !details.hidden;
|
||||
detailsToggle.textContent = details.hidden ? 'Show details' : 'Hide details';
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* view", "View name", `Search: <q>` or "My workspace view", Cancel / Save; an empty name is "Saved view".
|
||||
* - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/<id>` (a1158–1162).
|
||||
* - A view's state always holds every key of its page (the API requires them all); a value its page no longer
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can.
|
||||
* accepts is dropped to that key's default, so an opened view applies what it can (a view saved under the retired
|
||||
* "Highest rated" sort opens on Recently added, D153).
|
||||
* - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name",
|
||||
* Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`.
|
||||
*/
|
||||
@@ -36,7 +37,7 @@ const tags = { fallback: [], accepts: value => Array.isArray(value) && value.len
|
||||
typeof tag === 'string' && tag.trim() && tag.length <= MAX_TAG_LENGTH && !tag.includes(',') && !CONTROL.test(tag)) };
|
||||
const FIELDS = Object.freeze({
|
||||
images: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'),
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc'], 'newest'),
|
||||
source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) },
|
||||
folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags,
|
||||
// A view saved on Favorites (#249 D51) reopens Images filtered to favorites.
|
||||
@@ -45,7 +46,7 @@ const FIELDS = Object.freeze({
|
||||
collection: { fallback: '', accepts: value => typeof value === 'string' && /^(|[1-9][0-9]{0,8})$/.test(value) },
|
||||
}),
|
||||
library: Object.freeze({
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags,
|
||||
q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name'], 'newest'), tags,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
|
||||
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
|
||||
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
|
||||
* - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals".
|
||||
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
|
||||
* rated-images total).
|
||||
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
|
||||
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
|
||||
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
|
||||
@@ -168,21 +169,16 @@ export function healthSummary(report) {
|
||||
return `${time}Nothing was changed; review the checks with issues.`;
|
||||
}
|
||||
|
||||
/** D96: the concept's "Runs by category" rows `[category, runs, average rating]`. */
|
||||
/** D96: the concept's "Runs by category" rows `[category, runs]`; D153 drops its average rating column. */
|
||||
export function categoryRows(statistics) {
|
||||
return statistics.by_category.map(row => [
|
||||
row.category ?? 'Uncategorized',
|
||||
number(row.runs),
|
||||
row.average_rating === null || row.average_rating === undefined ? 'Not rated'
|
||||
: `${Number(row.average_rating).toLocaleString(undefined, { maximumFractionDigits: 2 })} / 5`,
|
||||
]);
|
||||
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
|
||||
}
|
||||
|
||||
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics and the rated images). */
|
||||
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
|
||||
export function totalsRows(statistics) {
|
||||
const { totals } = statistics;
|
||||
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images],
|
||||
['Rated images', statistics.rated_images]].map(([label, value]) => [label, number(value)]);
|
||||
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
|
||||
.map(([label, value]) => [label, number(value)]);
|
||||
}
|
||||
|
||||
function table(caption, head, rows, empty) {
|
||||
@@ -215,7 +211,7 @@ function table(caption, head, rows, empty) {
|
||||
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
|
||||
export function statisticsNodes(statistics) {
|
||||
return [
|
||||
table('Runs by category', ['Category', 'Runs', 'Avg. rating'], categoryRows(statistics), 'No prompts in the library yet.'),
|
||||
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
|
||||
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
|
||||
];
|
||||
}
|
||||
@@ -378,7 +374,7 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
|
||||
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
|
||||
label: 'Appearance', onClick: go('appearance') }),
|
||||
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
|
||||
operationRow({ scope, title: 'Statistics', description: 'Runs and ratings by category, with library totals.', label: 'Explore',
|
||||
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
|
||||
onClick: openStatistics }),
|
||||
]);
|
||||
const columns = el('div', undefined, 'two-col');
|
||||
|
||||
@@ -13,6 +13,7 @@ export const SEARCH_DELAY = 250;
|
||||
|
||||
// The concept's sort labels, in the concept's order. Matching is by exact label (note the en dash in "Name A–Z"); any
|
||||
// other label is one of the page's appended options.
|
||||
// No page passes "Highest rated" since D153 (#447); it stays here only to keep the concept's order.
|
||||
const CONCEPT_SORT_LABELS = Object.freeze(['Recently added', 'Name A–Z', 'Highest rated']);
|
||||
|
||||
const isText = value => typeof value === 'string';
|
||||
|
||||
Reference in New Issue
Block a user