From 56d0a4904ca2dc43cbc60c35b5de1b95218fbd3e Mon Sep 17 00:00:00 2001 From: Vito Date: Wed, 28 Oct 2026 21:45:09 +0000 Subject: [PATCH] feat(collections): COLLECTIONS sidebar, collection page and saved views (#331, PR 2) (#410) * feat(saved-views): keep a collection in a saved view Images saved views gain a collection key (#331 D140): empty or a collection id of 1 to 9 digits. Writes refuse favorites together with a collection; older rows read an empty collection, and a stored row with both opens the collection, as the listing does for a URL with both. Part of #331 * feat(collections): store collections in the library database * feat(collections): add, remove and rename collections and keep members through duplicate cleanup * feat(collections): add collection routes and the Images collection filter * feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * fix(collections): address review findings * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * feat(collections): browse a collection from the sidebar The sidebar gains the concept's COLLECTIONS group (D136): one row per collection with its folder icon, name and listed-member count, without the PROPOSED badge; the label shows once a collection exists. A row opens #/collection/, the Images page filtered to that collection (D137): the collection's name as h1, Collection as breadcrumb and title, Edit to rename or delete it through the saved views' Edit dialog with collection nouns, and a note counting members the sources do not list now. An unknown or deleted collection shows a page that says so with a link to Images. A saved view on a deleted collection lists nothing and says so (MC6, D140). The list is read again after a restore and the counts 300 ms after a change. Part of #331 * test(collections): compare the COLLECTIONS group with the concept The concept hides COLLECTIONS at 950 px and below, which would leave a phone no way to open a collection; production shows the rows there in the wrapped layout of every other sidebar nav (D136). The shell comparison now compares the group instead of filtering it out: the concept fixture seeds Landscapes (12) and Experiments (8), the rows compare by name order, and at phone width the nav compares against the concept's own sidebar-nav layout. The collection rows are checked at 1600x1100 and 390x844 in both themes. Part of #331 * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(browser): install the slideshow clock before wrapping setTimeout The slideshow fixture wrapped window.setTimeout before page.clock.install(), so the viewer's timer ran on real time and clock.runFor() did not drive it; a late WebKit timer failed 'slideshow exposes exact state labels' on CI. * fix(collections): address review findings - A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331 * test(coverage): keep main's floors for files this PR does not touch coverage:update raised nine floors of untouched files from one run; metadata-autocomplete.js branches then measured 84.84 against 85. * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged. * test(duplicates): a removed copy with a collection and a category moves both (#331, #332) --- database/saved_views.py | 14 + tests/browser/server.py | 2 + tests/browser/shell-concept.spec.mjs | 42 +- tests/browser/workspace-collections.spec.mjs | 493 ++++++++++++++++++ tests/browser/workspace-saved-views.spec.mjs | 5 +- tests/browser/workspace-system.spec.mjs | 2 +- tests/browser/workspace.spec.mjs | 14 +- .../browser/workspace_collections_fixture.py | 131 +++++ tests/browser/workspace_concept_fixture.py | 20 + .../workspace_library_editor_fixture.py | 2 + tests/browser/workspace_review_fixture.py | 2 + tests/browser/workspace_tagging_fixture.py | 2 + tests/browser/workspace_visual_fixture.py | 2 + tests/browser/workspace_vocabulary_fixture.py | 2 + tests/js/workspace-collections.test.js | 313 +++++++++++ tests/js/workspace-images-state.test.js | 18 + tests/js/workspace-saved-views.test.js | 25 +- tests/js/workspace-state.test.js | 26 + tests/test_saved_views.py | 55 ++ tools/vendor/frontend-coverage-baseline.json | 6 + web/js/workspace/collections.mjs | 314 +++++++++++ web/js/workspace/images-state.mjs | 8 + web/js/workspace/images.mjs | 48 +- web/js/workspace/index.mjs | 153 +++++- web/js/workspace/saved-views.mjs | 95 +++- web/js/workspace/state.mjs | 15 +- web/js/workspace/workspace.css | 3 + web/workspace.html | 3 + 28 files changed, 1743 insertions(+), 72 deletions(-) create mode 100644 tests/browser/workspace-collections.spec.mjs create mode 100644 tests/browser/workspace_collections_fixture.py create mode 100644 tests/js/workspace-collections.test.js create mode 100644 web/js/workspace/collections.mjs diff --git a/database/saved_views.py b/database/saved_views.py index 7652c59a..3a4d7afa 100644 --- a/database/saved_views.py +++ b/database/saved_views.py @@ -35,6 +35,9 @@ _LIBRARY_SORTS = ("newest", "oldest", "name", "rating") _MEDIA = ("all", "image", "video") _CONTROL = re.compile(r"[\x00-\x1f\x7f]") _SOURCE = re.compile(r"all|0|[1-9][0-9]{0,8}") +# A collection id as the listing's ``collection`` query takes it (#331): none +# ("") or 1 to 9 digits without a leading zero. +_COLLECTION = re.compile(r"|[1-9][0-9]{0,8}") FALLBACK_NAME = "Saved view" @@ -55,6 +58,9 @@ _STATE_FIELDS = { "tags": ("tags", None, ()), # The Favorites page's filter (#249 D51): a view saved there reopens it. "favorites": ("bool", None, False), + # A view saved on a collection page (#331 D140) reopens that + # collection; never together with favorites. + "collection": ("collection", None, ""), }, "library": { "q": ("text", 500, ""), @@ -125,6 +131,8 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None: raise SavedViewInvalid(f"State '{key}' is not a supported value") if kind == "source" and not _SOURCE.fullmatch(value): raise SavedViewInvalid(f"State '{key}' must be 'all' or a source index") + if kind == "collection" and not _COLLECTION.fullmatch(value): + raise SavedViewInvalid(f"State '{key}' must be empty or a collection id") if kind == "text" and (len(value) > limit or _CONTROL.search(value)): raise SavedViewInvalid(f"State '{key}' is too long or has control characters") @@ -142,6 +150,8 @@ def encode_state(page: object, state: object) -> str: ) for key, (kind, limit, _default) in fields.items(): _validate_value(key, kind, limit, state[key]) + if state.get("favorites") is True and state.get("collection"): + raise SavedViewInvalid("A view cannot filter by favorites and a collection") encoded = json.dumps( {key: state[key] for key in fields}, ensure_ascii=False, separators=(",", ":") ) @@ -181,6 +191,10 @@ def normalize_state(page: str, raw: object) -> dict: except SavedViewInvalid: value = default state[key] = list(value) if kind == "tags" else value + # Never written by this build; a stored row with both opens the collection, + # as the listing does for a URL with both (#331 D140). + if state.get("favorites") is True and state.get("collection"): + state["favorites"] = False return state diff --git a/tests/browser/server.py b/tests/browser/server.py index ef7e24cd..da867594 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -47,6 +47,7 @@ from tests.browser.workspace_library_editor_fixture import ( from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands from tests.browser.workspace_tagging_fixture import register_tagging_commands from tests.browser.workspace_review_fixture import register_review_commands +from tests.browser.workspace_collections_fixture import register_collections_commands from tests.browser.workspace_diagnostics_fixture import ( register_diagnostics_commands, repoint_logs, @@ -232,6 +233,7 @@ def create_app(root: Path) -> web.Application: register_vocabulary_commands(routes, api, root) register_tagging_commands(routes, api, root, model) register_review_commands(routes, api, root) + register_collections_commands(routes, api, root) reveal_stub = register_reveal_stub(routes, api, root) register_activity_commands(app, routes, api) register_system_commands(app, routes, api, root) diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index 878b9c55..2a79d67f 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -76,13 +76,41 @@ function savedNav(nav) { return {...nav,rows:nav.rows.map(row=>row.text==='Needs review'?{...row,count:{...row.count,text:'0'}}:row)}; } +/** + * D136 (#331): the concept's COLLECTIONS rows (app.js a336–339) as production lists them, by name (DC1): the fixture's + * "Landscapes" and "Experiments" hold the concept's 12 and 8 images, so text and counts compare as they are and each + * place keeps the concept's row geometry. At <=950px the concept hides the nav, which would leave a phone no way to + * open a collection: production shows it as the concept lays out every other sidebar nav there (its SAVED VIEWS nav), + * its rows styled as that nav's rows. Text, widths and wrap gaps then follow the rows' own content. + */ +function collectionsNav(nav,navs) { + const byName=[...nav.rows].sort((a,b)=>a.textb.text?1:0); + if(nav.shown)return {...nav,rows:byName.map((row,index)=>({...row,gapAfter:nav.rows[index].gapAfter}))}; + const saved=navs.find(item=>item.id==='saved-nav'); + const {id,label,rows,...frame}=saved; + return {...nav,...frame,id:nav.id,label:nav.label,rows:byName.map(row=>({...rows[0],text:row.text, + count:row.count&&rows[0].count?{...rows[0].count,text:row.count.text}:rows[0].count, + icon:rows[0].icon&&row.icon?{...rows[0].icon,d:row.icon.d}:row.icon, + width:'(content)',hugs:'(content)',gapAfter:'(content)',current:null}))}; +} + +/** The production collections nav at <=950px with its content-sized values replaced, as collectionsNav maps them. */ +function contentFree(navs) { + return navs.map(nav=>nav.id!=='collections-nav'||!nav.shown?nav:{...nav, + rows:nav.rows.map(row=>({...row,width:'(content)',hugs:'(content)',gapAfter:'(content)'}))}); +} + +/** D136: where the concept hides COLLECTIONS, the production rows' content-sized values are not compared. */ +function comparableNavs(reference,navs) { + return reference.chrome.navs.find(nav=>nav.id==='collections-nav')?.shown?navs:contentFree(navs); +} + /** Recorded shell deviations only (spec §8). Everything else in the concept inventory must equal production. */ function mapShellConcept(reference) { const {chrome}=reference; const navs=chrome.navs - // D28: COLLECTIONS is a proposal with no persisted store, so its nav is not rendered. - .filter(nav=>nav.id!=='collections-nav') .map(nav=>{ + if(nav.id==='collections-nav')return collectionsNav(nav,chrome.navs); if(nav.id==='saved-nav')return savedNav(nav); if(nav.id!=='system-nav')return nav; const system=rowNamed(nav,'System'); @@ -99,9 +127,8 @@ function mapShellConcept(reference) { return {...reference,chrome:{...chrome, // D29: no prototype suffix in the document title. title:chrome.title.replace(/ · Prompt Manager concept$/,' · Prompt Manager'), - // D28: no COLLECTIONS label. D49: SAVED VIEWS without its "PROPOSED" badge. - labels:chrome.labels.filter(label=>label.text!=='COLLECTIONS PROPOSED') - .map(label=>label.text==='SAVED VIEWS PROPOSED'?{...label,text:'SAVED VIEWS'}:label), + // D49: SAVED VIEWS without its "PROPOSED" badge. D136: COLLECTIONS without it either. + labels:chrome.labels.map(label=>label.text.endsWith(' PROPOSED')?{...label,text:label.text.replace(/ PROPOSED$/,'')}:label), navs, connection:{...chrome.connection, // D28: truthful copy instead of "Demo connection · Classic / Nodes 2.0". @@ -134,8 +161,9 @@ function headingFrame(heading) { function compare(route,reference,actual) { const expected=mapShellConcept(reference); const heading=FULL_HEADING.has(route); + const chrome={...actual.chrome,navs:comparableNavs(reference,actual.chrome.navs)}; return [ - ...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome:actual.chrome,noPageScroll:actual.noPageScroll}), + ...diffInventory({chrome:expected.chrome,noPageScroll:expected.noPageScroll},{chrome,noPageScroll:actual.noPageScroll}), ...diffInventory(heading?expected.heading:headingFrame(expected.heading),heading?actual.heading:headingFrame(actual.heading)) .map(item=>({...item,path:`heading.${item.path}`})), ]; @@ -242,7 +270,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $ expect(reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>row.text)).toEqual(['Favorites','Needs review','Golden hour']); await openProduction(page,'images','dark'); const actual=await save('Save','#workspace-main'); - expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]); + expect(diffInventory(mapShellConcept(reference).chrome.navs,comparableNavs(reference,actual.chrome.navs))).toEqual([]); const referenceRows=reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows; expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count])) .toEqual([['Favorites',referenceRows[0].count],['Needs review',{...referenceRows[1].count,text:'0'}],['Golden hour',null]]); diff --git a/tests/browser/workspace-collections.spec.mjs b/tests/browser/workspace-collections.spec.mjs new file mode 100644 index 00000000..b87a55b4 --- /dev/null +++ b/tests/browser/workspace-collections.spec.mjs @@ -0,0 +1,493 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; + +// Collections (#331): the sidebar COLLECTIONS group (D136, concept index.html 28–31, app.js 336–339), the collection +// page `#/collection/` with its Edit (D137, concept render a1186–1189) and saved views on a collection (D140). +const workspace = '/prompt_manager/workspace'; +const API = '/prompt_manager/collections'; +const main = page => page.locator('#workspace-main'); +const heading = page => main(page).getByRole('heading', { level: 1 }); +const breadcrumb = page => page.locator('#workspace-breadcrumb'); +const description = page => main(page).locator('.heading p.muted'); +const missingNote = page => main(page).locator('.heading p.small'); +const search = page => page.getByRole('searchbox', { name: 'Search workspace', exact: true }); +const imageCards = page => page.locator('article[data-image-key]'); +const collectionsNav = page => page.getByRole('navigation', { name: 'Collections', exact: true }); +const collectionsLabel = page => page.locator('aside.sidebar .sidebar-label', { hasText: 'COLLECTIONS' }); +const rows = page => collectionsNav(page).locator('a[data-route^="collection/"]'); +const row = (page, name) => rows(page).filter({ hasText: name }); +const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true }); +const editButton = page => main(page).locator('.heading-actions > button').first(); +const editDialog = page => page.getByRole('dialog', { name: 'Edit collection', exact: true }); +const deleteDialog = page => page.getByRole('dialog', { name: 'Delete collection', exact: true }); +const nameField = page => editDialog(page).getByRole('textbox', { name: 'Collection name', exact: true }); +const toast = page => page.locator('#workspace-toast'); + +async function seed(request) { + const response = await request.post('/__test__/collections/seed'); + await expect(response).toBeOK(); + return response.json(); +} + +async function listed(request) { + const response = await request.get(API); + await expect(response).toBeOK(); + return (await response.json()).collections; +} + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await expect(await request.post('/__test__/reset')).toBeOK(); + page.collectionErrors = []; + page.on('pageerror', error => page.collectionErrors.push(error.message)); + // D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open. + page.on('dialog', dialog => { page.collectionErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); }); +}); + +test.afterEach(async ({ page, browserName }) => { + await saveCoverage(page, browserName); + expect(page.collectionErrors).toEqual([]); +}); + +test('a collection row opens its page', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.setViewportSize({ width: 1600, height: 1100 }); + await page.goto(`${workspace}#/images`); + // D136: name order, the listed-member count, no "PROPOSED" badge. + await expect(collectionsLabel(page)).toBeVisible(); + await expect(collectionsLabel(page)).toHaveText('COLLECTIONS'); + await expect(rows(page)).toHaveCount(2); + await expect(rows(page).nth(0)).toContainText('Experiments'); + await expect(rows(page).nth(1)).toContainText('Landscapes'); + await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8'); + await expect(row(page, 'Landscapes').locator('.nav-count')).toHaveText('12'); + await expect(row(page, 'Landscapes')).toHaveAttribute('href', `#/collection/${id}`); + await expect(row(page, 'Landscapes').locator('svg')).toHaveCount(1); + await expect(page.locator('aside.sidebar .new-label')).toHaveCount(0); + await expect(imageCards(page)).toHaveCount(21); + + await row(page, 'Landscapes').click(); + await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`)); + await expect(heading(page)).toHaveText('Landscapes'); + await expect(breadcrumb(page)).toHaveText('Collection'); + await expect(page).toHaveTitle('Collection · Prompt Manager'); + await expect(row(page, 'Landscapes')).toHaveAttribute('aria-current', 'page'); + await expect(description(page)).toHaveText('Your generations, their prompts, and the next idea.'); + await expect(imageCards(page)).toHaveCount(12); + for (const path of manifest.files.Landscapes) { + await expect(page.locator(`article[data-image-key$=":${path}"]`)).toHaveCount(1); + } + // MC3: the member whose file is gone is not listed and is counted in the note. + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); + + // Every Images filter narrows the members. + await search(page).fill('landscape_03'); + await expect(imageCards(page)).toHaveCount(1); + await search(page).fill(''); + await expect(imageCards(page)).toHaveCount(12); + + await row(page, 'Experiments').click(); + await expect(heading(page)).toHaveText('Experiments'); + await expect(imageCards(page)).toHaveCount(8); + await expect(missingNote(page)).toHaveCount(0); +}); + +test('without collections the group shows no label and no rows', async ({ page }) => { + await page.goto(`${workspace}#/images`); + await expect(imageCards(page).first()).toBeVisible(); + await expect(collectionsNav(page)).toHaveCount(1); + await expect(rows(page)).toHaveCount(0); + await expect(collectionsLabel(page)).toBeHidden(); +}); + +test('a collection page neither restores nor replaces the Images return state', async ({ page, request }) => { + const manifest = await seed(request); + await page.goto(`${workspace}#/images`); + await search(page).fill('experiment'); + await expect(imageCards(page)).toHaveCount(8); + await row(page, 'Landscapes').click(); + await expect(search(page)).toHaveValue(''); + await expect(imageCards(page)).toHaveCount(12); + await search(page).fill('landscape_01'); + await expect(imageCards(page)).toHaveCount(1); + await page.goto(`${workspace}#/images`); + await expect(search(page)).toHaveValue('experiment'); + expect(manifest.collections.Landscapes.count).toBe(12); +}); + +test('Edit renames the open collection; a taken name stays in the dialog', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(heading(page)).toHaveText('Landscapes'); + await expect(editButton(page)).toHaveText('Edit'); + await editButton(page).click(); + await expect(nameField(page)).toHaveValue('Landscapes'); + await expect(editDialog(page).locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']); + + // CUJ 3: names are unique ignoring case. + await nameField(page).fill('experiments'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(editDialog(page).getByRole('alert')).toHaveText('A collection named “experiments” already exists.'); + await expect(heading(page)).toHaveText('Landscapes'); + + await nameField(page).fill(' Landscape grids '); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(editDialog(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('Collection renamed.'); + await expect(heading(page)).toHaveText('Landscape grids'); + await expect(breadcrumb(page)).toHaveText('Collection'); + await expect(row(page, 'Landscape grids')).toHaveAttribute('aria-current', 'page'); + await expect(rows(page).nth(1)).toContainText('Landscape grids'); + expect((await listed(request)).map(item => item.name)).toEqual(['Experiments', 'Landscape grids']); +}); + +test('a failed rename keeps the dialog; a failed delete changes nothing', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + await page.goto(`${workspace}#/collection/${id}`); + await expect(heading(page)).toHaveText('Experiments'); + let failing = true; + await page.route(`**${API}/${id}`, route => failing + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"The collection could not be changed."}' }) + : route.fallback()); + await editButton(page).click(); + await nameField(page).fill('Tests'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('The collection could not be renamed. Try again.'); + await expect(editDialog(page)).toBeVisible(); + await expect(heading(page)).toHaveText('Experiments'); + await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click(); + await expect(deleteDialog(page).locator('p')).toHaveText( + 'Delete “Experiments”? Its 8 images stay where they are; only the collection is removed.'); + await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(toast(page)).toHaveText('The collection could not be deleted. Try again.'); + await expect(page).toHaveURL(new RegExp(`#/collection/${id}$`)); + expect(await listed(request)).toHaveLength(2); + failing = false; +}); + +test('Delete removes the collection only after confirming, and leaves every file', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await editButton(page).click(); + await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click(); + // The stored count: 12 listed members and the missing one. + await expect(deleteDialog(page).locator('p')).toHaveText( + 'Delete “Landscapes”? Its 13 images stay where they are; only the collection is removed.'); + await deleteDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(deleteDialog(page)).toHaveCount(0); + expect(await listed(request)).toHaveLength(2); + + await editButton(page).click(); + await editDialog(page).getByRole('button', { name: 'Delete…', exact: true }).click(); + await deleteDialog(page).getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(toast(page)).toHaveText('Collection deleted.'); + await expect(page).toHaveURL(/#\/images$/); + await expect(heading(page)).toHaveText('Images'); + await expect(rows(page)).toHaveCount(1); + await expect(imageCards(page)).toHaveCount(21); + expect((await listed(request)).map(item => item.name)).toEqual(['Experiments']); +}); + +test('an unknown collection says it no longer exists, with the way back to Images', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/collection/999999`); + await expect(heading(page)).toHaveText('Collection'); + await expect(breadcrumb(page)).toHaveText('Collection'); + await expect(description(page)).toHaveText('This collection no longer exists.'); + await expect(imageCards(page)).toHaveCount(0); + await main(page).getByRole('link', { name: 'Open Images', exact: true }).click(); + await expect(page).toHaveURL(/#\/images$/); + await expect(imageCards(page)).toHaveCount(21); +}); + +test('a failed list says so and Retry reads it again', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + let failing = true; + await page.route(`**${API}`, route => failing + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Collections could not be read."}' }) + : route.fallback()); + await page.goto(`${workspace}#/collection/${id}`); + await expect(description(page)).toHaveText('Collections could not be loaded. Your saved data has not changed.'); + await expect(description(page)).toHaveAttribute('role', 'alert'); + await expect(rows(page)).toHaveCount(0); + failing = false; + await main(page).getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(heading(page)).toHaveText('Experiments'); + await expect(imageCards(page)).toHaveCount(8); + await expect(rows(page)).toHaveCount(2); +}); + +test('a saved view keeps its collection, and says so once the collection is deleted (MC6)', async ({ page, request }) => { + const manifest = await seed(request); + const view = manifest.saved_view; + await page.goto(`${workspace}#/images`); + await savedNav(page).locator(`a[data-route="saved/${view.id}"]`).click(); + await expect(heading(page)).toHaveText(view.name); + await expect(breadcrumb(page)).toHaveText(view.name); + await expect(imageCards(page)).toHaveCount(8); + // A saved view is not the collection page: no missing note. + await expect(missingNote(page)).toHaveCount(0); + + // Save view on a collection page stores the collection. + await row(page, 'Landscapes').click(); + await expect(imageCards(page)).toHaveCount(12); + await search(page).fill('landscape_1'); + await expect(imageCards(page)).toHaveCount(3); + await main(page).getByRole('button', { name: 'Save view', exact: true }).click(); + const save = page.getByRole('dialog', { name: 'Save this view', exact: true }); + await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Grid seed'); + await save.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Saved view added to the sidebar.'); + const views = (await (await request.get('/prompt_manager/saved-views')).json()).views; + const saved = views.find(item => item.name === 'Grid seed'); + expect(saved.state).toMatchObject({ q: 'landscape_1', favorites: false, collection: String(manifest.collections.Landscapes.id) }); + + await expect(await request.delete(`${API}/${manifest.collections.Experiments.id}`)).toBeOK(); + await page.goto(`${workspace}#/saved/${view.id}`); + await expect(heading(page)).toHaveText(view.name); + await expect(main(page).locator('.empty')).toHaveText('The collection this view uses was deleted.'); + await expect(main(page).locator('.empty')).toBeVisible(); + await expect(imageCards(page)).toHaveCount(0); + await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeHidden(); +}); + +test('a collection deleted in another tab shows the deleted-collection page', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + await page.goto(`${workspace}#/images`); + await expect(rows(page)).toHaveCount(2); + await expect(await request.delete(`${API}/${id}`)).toBeOK(); + await row(page, 'Experiments').click(); + await expect(description(page)).toHaveText('This collection no longer exists.'); + await expect(rows(page)).toHaveCount(1); +}); + +// D136 / CUJ 15: the rows show and open at desktop and phone width in both themes. The concept's own phone rule hides +// every nav count there, so the rows keep their counts only at desktop width. +for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['dark', 'light']) { + test(`collection rows show and open at ${width}x${height} ${theme}`, async ({ page, request }) => { + const manifest = await seed(request); + await page.setViewportSize({ width, height }); + await page.goto(`${workspace}#/images`); + await page.getByRole('combobox', { name: 'Theme', exact: true }).selectOption(theme); + for (const name of ['Experiments', 'Landscapes']) await expect(row(page, name)).toBeVisible(); + const count = row(page, 'Landscapes').locator('.nav-count'); + if (width > 950) await expect(count).toBeVisible(); + else await expect(count).toBeHidden(); + // Phone width: the label stays hidden as every sidebar label is; desktop shows it. + if (width > 950) await expect(collectionsLabel(page)).toBeVisible(); + else await expect(collectionsLabel(page)).toBeHidden(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await row(page, 'Landscapes').click(); + await expect(page).toHaveURL(new RegExp(`#/collection/${manifest.collections.Landscapes.id}$`)); + await expect(heading(page)).toHaveText('Landscapes'); + await expect(imageCards(page)).toHaveCount(12); + }); +} + +test('a finished restore reads the collections again', async ({ page, request }) => { + await seed(request); + await expect(await request.post('/__test__/system/reset')).toBeOK(); + await expect(await request.post('/__test__/jobs/restart')).toBeOK(); + try { + const backup = await request.get('/prompt_manager/backup'); + await expect(backup).toBeOK(); + const bytes = await backup.body(); + const [experiments] = await listed(request); + await expect(await request.delete(`${API}/${experiments.id}`)).toBeOK(); + await page.goto(`${workspace}#/system/backups`); + await expect(rows(page)).toHaveCount(1); + const chooser = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Review restore…', exact: true }).click(); + await (await chooser).setFiles({ name: 'prompts_backup_20261027_093000.db', mimeType: 'application/octet-stream', buffer: bytes }); + await page.locator('dialog#modal[open]').getByRole('button', { name: 'Restore', exact: true }).click(); + await expect(toast(page)).toHaveText(/^Database restore finished · /); + // The list was read again without a reload. + await expect(rows(page)).toHaveCount(2); + await expect(row(page, 'Experiments').locator('.nav-count')).toHaveText('8'); + } finally { + await request.post('/__test__/jobs/restart'); + await request.post('/__test__/system/reset'); + } +}); + +test('renaming a collection deleted in another tab says so and shows the deleted-collection page', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + await page.goto(`${workspace}#/collection/${id}`); + await expect(heading(page)).toHaveText('Experiments'); + await editButton(page).click(); + await expect(await request.delete(`${API}/${id}`)).toBeOK(); + await nameField(page).fill('Tests'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('That collection no longer exists.'); + await expect(editDialog(page)).toHaveCount(0); + await expect(description(page)).toHaveText('This collection no longer exists.'); + await expect(rows(page)).toHaveCount(1); +}); + +/** A Playwright hold on the collections list read: `held` fills as reads arrive; `release()` lets them all through. */ +async function holdListReads(page, answer = route => route.fallback()) { + const held = []; + let open = false; + await page.route(`**${API}`, route => { + if (route.request().method() !== 'GET') return route.fallback(); + if (open) return answer(route); + held.push(route); + return undefined; + }); + return { + held, + async release() { + open = true; + for (const route of held.splice(0)) await answer(route).catch(() => {}); + }, + }; +} + +test('a list read superseded by another read still opens a collection made since (review M1)', async ({ page, request }) => { + const manifest = await seed(request); + const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1')).json(); + const [file] = listing.images; + await page.goto(`${workspace}#/collection/${manifest.collections.Landscapes.id}`); + await expect(heading(page)).toHaveText('Landscapes'); + // Another tab makes "Fresh"; this tab does not list it yet. + const added = await request.post(`${API}/items`, { data: { name: 'Fresh', + items: [{ root_index: file.root_index, root_scope: file.root_scope, path: file.path }] } }); + await expect(added).toBeOK(); + 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. + 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(main(page)).not.toContainText('Collections could not be loaded.'); + await hold.release(); + await expect(heading(page)).toHaveText('Fresh'); + await expect(imageCards(page)).toHaveCount(1); +}); + +test('a listing that finds the collection gone while the list still has it offers Retry (review M2)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Experiments; + let gone = true; + await page.route('**/prompt_manager/images/output?*', route => gone && route.request().url().includes(`collection=${id}`) + ? route.fulfill({ status: 404, contentType: 'application/json', + body: '{"success":false,"error":"Collection not found","code":"collection-not-found"}' }) + : route.fallback()); + await page.goto(`${workspace}#/images`); + await expect(rows(page)).toHaveCount(2); + // The shell's re-read is held, then fails: the page must not stay blank. + const hold = await holdListReads(page, route => route.fulfill({ status: 500, contentType: 'application/json', + body: '{"success":false,"error":"Collections could not be read."}' })); + await row(page, 'Experiments').click(); + await expect.poll(() => hold.held.length).toBe(1); + await hold.release(); + const retry = main(page).getByRole('button', { name: 'Retry', exact: true }); + await expect(retry).toBeVisible(); + await expect(main(page).locator('#gallery-continuation p')).toHaveText('Images could not be loaded. Retry or rescan the collection.'); + gone = false; + await page.unroute(`**${API}`); + await retry.click(); + await expect(imageCards(page)).toHaveCount(8); + + // The list read again still lists it: the same Retry, never a blank page. + gone = true; + await page.evaluate(() => { window.location.hash = '#/images'; }); + await expect(heading(page)).toHaveText('Images'); + await row(page, 'Experiments').click(); + await expect(retry).toBeVisible(); + gone = false; + await retry.click(); + await expect(imageCards(page)).toHaveCount(8); +}); + +test('the missing-members note follows the list read again (review L2)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + let missing = null; + await page.route(`**${API}`, async route => { + if (route.request().method() !== 'GET' || missing === null) return route.fallback(); + const response = await route.fetch(); + const data = await response.json(); + data.collections = data.collections.map(item => item.id === id ? { ...item, missing } : item); + return route.fulfill({ response, json: data }); + }); + await page.goto(`${workspace}#/collection/${id}`); + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); + const rename = async name => { + await editButton(page).click(); + await nameField(page).fill(name); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Collection renamed.'); + }; + missing = 3; + await rename('Landscapes A'); + await expect(missingNote(page)).toHaveText('3 images in this collection are not in your current sources.'); + missing = 0; + await rename('Landscapes B'); + await expect(missingNote(page)).toHaveCount(0); + missing = 1; + await rename('Landscapes C'); + await expect(missingNote(page)).toHaveText('1 image in this collection is not in your current sources.'); +}); + +test('a rename that settles after its dialog closed leaves the next dialog open (review L1)', async ({ page, request }) => { + const manifest = await seed(request); + const { id } = manifest.collections.Landscapes; + await page.goto(`${workspace}#/collection/${id}`); + await expect(imageCards(page)).toHaveCount(12); + let release; + const released = new Promise(resolve => { release = resolve; }); + await page.route(`**${API}/${id}`, async route => { + if (route.request().method() !== 'PATCH') return route.fallback(); + await released; + return route.fallback(); + }); + await editButton(page).click(); + await nameField(page).fill('Later'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await page.keyboard.press('Escape'); + await expect(editDialog(page)).toHaveCount(0); + await main(page).getByRole('button', { name: 'Save view', exact: true }).click(); + const save = page.getByRole('dialog', { name: 'Save this view', exact: true }); + await expect(save).toBeVisible(); + release(); + await expect(heading(page)).toHaveText('Later'); + await expect(save).toBeVisible(); +}); + +test('a page that says a collection or view could not be opened takes focus after a route change (review L3)', async ({ page, request }) => { + await seed(request); + await page.goto(`${workspace}#/images`); + await expect(heading(page)).toHaveText('Images'); + await page.evaluate(() => { window.location.hash = '#/collection/999999'; }); + await expect(description(page)).toHaveText('This collection no longer exists.'); + await expect(heading(page)).toBeFocused(); + + await page.route(`**${API}`, route => route.request().method() === 'GET' + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback()); + await page.evaluate(() => { window.location.hash = '#/images'; }); + await expect(heading(page)).toHaveText('Images'); + await page.evaluate(() => { window.location.hash = '#/collection/999998'; }); + await expect(main(page).getByRole('button', { name: 'Retry', exact: true })).toBeVisible(); + await expect(heading(page)).toBeFocused(); + + await page.route('**/prompt_manager/saved-views', route => route.request().method() === 'GET' + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"x"}' }) + : route.fallback()); + await page.evaluate(() => { window.location.hash = '#/saved/5'; }); + await expect(description(page)).toHaveText('Saved views could not be loaded. Your saved data has not changed.'); + await expect(heading(page)).toBeFocused(); +}); diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index 85a6ecab..b29f16a1 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -23,7 +23,7 @@ const heading = page => main(page).getByRole('heading', { level: 1 }); const saveDialog = page => page.getByRole('dialog', { name: 'Save this view', exact: true }); const refineDialog = page => page.getByRole('dialog', { name: 'Refine this view', exact: true }); const toast = page => page.locator('#workspace-toast'); -const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false }; +const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' }; async function refine(page, values) { await filtersButton(page).click(); @@ -73,7 +73,8 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row await page.goto(`${workspace}#/images`); await expect(imageCards(page)).toHaveCount(2); // The concept's sidebar group: SAVED VIEWS without its PROPOSED badge (D49), empty until a view is saved. - await expect(page.locator('aside.sidebar .sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']); + // D136: the COLLECTIONS label stays hidden while no collection is listed. + await expect(page.locator('aside.sidebar .sidebar-label:not([hidden])')).toHaveText(['WORKSPACE', 'SAVED VIEWS']); await expect(viewRows(page)).toHaveCount(0); await search(page).fill('dogs'); diff --git a/tests/browser/workspace-system.spec.mjs b/tests/browser/workspace-system.spec.mjs index 7dab8663..db75c85a 100644 --- a/tests/browser/workspace-system.spec.mjs +++ b/tests/browser/workspace-system.spec.mjs @@ -472,7 +472,7 @@ async function openImages(page) { test('defaults apply to a new visit but not to the current view or a saved view', async ({ page, request }) => { const created = await request.post('/prompt_manager/saved-views', { data: { page: 'images', name: 'Everything', - state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false } } }); + state: { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' } } }); expect(created.status()).toBe(201); const view = (await created.json()).view; try { diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index fe11a135..3dd4b920 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -548,12 +548,16 @@ test('sidebar matches the concept groups and shows real counts', async ({ page, await expect(page.locator('.workspace-shell, .workspace-body, .sidebar-footer, .connection-dot, .workspace-icon')).toHaveCount(0); const sidebar = page.locator('aside.sidebar'); expect(await childShape(sidebar)).toEqual(['a.brand', 'div.sidebar-label', 'nav#primary-nav', 'div.sidebar-label', - 'nav#saved-nav', 'div.sidebar-bottom']); + 'nav#saved-nav', 'div.sidebar-label', 'nav#collections-nav', 'div.sidebar-bottom']); await expect(sidebar.locator('.brand')).toHaveAttribute('href', '#/images'); - // D49: SAVED VIEWS without the concept's PROPOSED badge; D28: still no COLLECTIONS. - await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS']); + // D49: SAVED VIEWS without the concept's PROPOSED badge. D136: COLLECTIONS without it either, its label hidden + // and its nav empty while no collection exists. + await expect(sidebar.locator('.sidebar-label')).toHaveText(['WORKSPACE', 'SAVED VIEWS', 'COLLECTIONS']); + await expect(sidebar.locator('.sidebar-label').nth(2)).toBeHidden(); await expect(page.locator('nav#saved-nav')).toHaveAttribute('aria-label', 'Saved views'); - await expect(page.locator('#collections-nav, .new-label')).toHaveCount(0); + await expect(page.locator('nav#collections-nav')).toHaveAttribute('aria-label', 'Collections'); + await expect(page.locator('nav#collections-nav > a')).toHaveCount(0); + await expect(page.locator('.new-label')).toHaveCount(0); const primary = page.locator('nav#primary-nav'); await expect(primary).toHaveAttribute('aria-label', 'Workspace'); for (const [id] of NAV_TOTALS) await expect(navCount(page, id)).toHaveText(totals[id].toLocaleString('en-US')); @@ -580,7 +584,7 @@ test('sidebar matches the concept groups and shows real counts', async ({ page, // At phone width the concept wraps the links and hides counts and the connection (concept 950px block). await page.setViewportSize({ width: 390, height: 844 }); await expect(navCount(page, 'library')).toBeHidden(); - await expect(page.locator('.sidebar-label, .connection')).toHaveCount(3); + await expect(page.locator('.sidebar-label, .connection')).toHaveCount(4); for (const hidden of await page.locator('.sidebar-label, .connection').all()) await expect(hidden).toBeHidden(); await expect(primary.locator('a[href="#/inspect"]')).toBeVisible(); }); diff --git a/tests/browser/workspace_collections_fixture.py b/tests/browser/workspace_collections_fixture.py new file mode 100644 index 00000000..7f815a3a --- /dev/null +++ b/tests/browser/workspace_collections_fixture.py @@ -0,0 +1,131 @@ +"""Owned, deterministic collections for the workspace collection workflows (#331). + +Twenty-one PNGs under the output source: twelve listed members of +"Landscapes", eight of "Experiments" and one loose file in neither. Landscapes +also holds one member whose file does not exist (MC3: hidden, counted as +missing). One saved view reopens "Experiments" (D140). Members are added +through ``ImageCollectionOperations`` with favorites' keys +(``_favorite_root_key``, ``_favorite_relative_key``), as the routes add them. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING + +from aiohttp import web +from PIL import Image +from tests.browser.folder_trash import replace_folder + +if TYPE_CHECKING: + from py.api import PromptManagerAPI + +FOLDER = "collections" +LANDSCAPES = "Landscapes" +EXPERIMENTS = "Experiments" +LANDSCAPE_FILES = tuple(f"landscape_{index:02d}.png" for index in range(1, 13)) +EXPERIMENT_FILES = tuple(f"experiment_{index:02d}.png" for index in range(1, 9)) +LOOSE_FILE = "loose.png" +MISSING_FILE = "gone.png" +SAVED_VIEW = "Experiments view" +_TABLES = ( + "image_collection_items", + "image_collections", + "saved_views", + "image_favorites", + "media_review_decisions", + "media_review_items", + "media_image_tags", + "media_annotation_bytes", + "media_annotation_files", + "image_prompt_pairs", + "prompt_pair_tags", + "prompt_pairs", + "generated_images", + "prompt_tags", + "tags", + "prompt_library_state", + "prompts", +) +_IMAGES_STATE = { + "q": "", + "category": "", + "sort": "newest", + "source": "all", + "folder": "", + "media": "all", + "tags": [], + "favorites": False, +} + + +def _write_png(path: Path, shade: int, stamp: float) -> None: + with Image.new("RGB", (64, 48), (shade, 120, 255 - shade)) as image: + image.save(path) + os.utime(path, (stamp, stamp)) + + +def seed_collections(api: PromptManagerAPI, root: Path) -> dict: + """Replace this server's disposable rows and files with the two collections.""" + # The API package needs the host stubs the server installs before importing it. + from py.api.images import _favorite_relative_key, _favorite_root_key + + db = api.db + with db.model.get_connection() as conn: + for table in _TABLES: + conn.execute(f"DELETE FROM {table}") + for name in ("output", "input"): + replace_folder(root / name) + folder = root / "output" / FOLDER + folder.mkdir() + # Newest first in this order: Landscapes, then Experiments, then the loose file. + now = time.time() + ordered = (*LANDSCAPE_FILES, *EXPERIMENT_FILES, LOOSE_FILE) + for index, name in enumerate(ordered): + _write_png(folder / name, (index * 11) % 256, now - 60 - index * 60) + root_key = _favorite_root_key(list(api._get_all_output_dirs())[0]) + members = { + LANDSCAPES: (*LANDSCAPE_FILES, MISSING_FILE), + EXPERIMENTS: EXPERIMENT_FILES, + } + ids = {} + for name, files in members.items(): + added = db.add_to_collection( + name=name, + items=[ + (root_key, _favorite_relative_key(f"{FOLDER}/{file}")) for file in files + ], + ) + ids[name] = added["collection"]["id"] + view = db.create_saved_view( + "images", SAVED_VIEW, {**_IMAGES_STATE, "collection": str(ids[EXPERIMENTS])} + ) + api.invalidate_gallery_cache() + return { + "collections": { + LANDSCAPES: {"id": ids[LANDSCAPES], "count": 12, "missing": 1}, + EXPERIMENTS: {"id": ids[EXPERIMENTS], "count": 8, "missing": 0}, + }, + "files": { + LANDSCAPES: [f"{FOLDER}/{file}" for file in LANDSCAPE_FILES], + EXPERIMENTS: [f"{FOLDER}/{file}" for file in EXPERIMENT_FILES], + "loose": f"{FOLDER}/{LOOSE_FILE}", + }, + "saved_view": {"id": view["id"], "name": SAVED_VIEW}, + } + + +def register_collections_commands( + routes: web.RouteTableDef, api: PromptManagerAPI, root: Path +) -> None: + """Register the fixed seed command; no path or SQL comes from a request.""" + + @routes.post("/__test__/collections/seed") + async def seed(request: web.Request) -> web.Response: + data = await request.json() if request.can_read_body else {} + if data != {} or request.query: + raise web.HTTPBadRequest() + manifest = await api._run_in_executor(seed_collections, api, root) + return web.json_response({"success": True, **manifest}) diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 0c54e831..44d77ca3 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -174,6 +174,8 @@ _TABLES = ( "execution_contexts", "execution_prompt_snapshots", "execution_context_texts", + "image_collection_items", + "image_collections", "saved_views", "image_favorites", "media_review_decisions", @@ -193,6 +195,10 @@ _TABLES = ( ) +# The concept's collections and the slice of its images each holds (#331). +CONCEPT_COLLECTIONS = (("Landscapes", slice(0, 12)), ("Experiments", slice(12, 20))) + + def concept_starred(prompt_index: int, round_index: int) -> bool: """The concept's favorites (app.js ``starred``): each other prompt's first image.""" return round_index == 0 and prompt_index % 2 == 0 @@ -495,6 +501,19 @@ def seed_workspace_concept( ) images.reverse() runs.reverse() + # The concept's COLLECTIONS rows (app.js a336–339): "Landscapes" holds the + # first 12 images in the concept's order and "Experiments" the next 8, so + # the rows and counts compare as the concept shows them (#331 D136). + collections = {} + for name, members in CONCEPT_COLLECTIONS: + added = db.add_to_collection( + name=name, + items=[ + (favorite_root, _favorite_relative_key(image["path"])) + for image in images[members] + ], + ) + collections[name] = added["collection"]["id"] seedless = [] for number in range(seedless_images): name = f"seedless_{number + 1:05}.png" @@ -531,4 +550,5 @@ def seed_workspace_concept( "images": images, "runs": runs, "tags": tags, + "collections": collections, } diff --git a/tests/browser/workspace_library_editor_fixture.py b/tests/browser/workspace_library_editor_fixture.py index ffc0534a..25d06b08 100644 --- a/tests/browser/workspace_library_editor_fixture.py +++ b/tests/browser/workspace_library_editor_fixture.py @@ -26,6 +26,8 @@ EDITOR_METADATA = { "notes": "Keep the soft light.", } _TABLES = ( + "image_collection_items", + "image_collections", "saved_views", "image_favorites", "media_review_decisions", diff --git a/tests/browser/workspace_review_fixture.py b/tests/browser/workspace_review_fixture.py index 16344eb2..da5847be 100644 --- a/tests/browser/workspace_review_fixture.py +++ b/tests/browser/workspace_review_fixture.py @@ -78,6 +78,8 @@ LIMITED_SAMPLERS = 17 # More than one queue page (40), so Load more has a second page. MAX_ITEMS = 45 _TABLES = ( + "image_collection_items", + "image_collections", "saved_views", "image_favorites", "media_review_decisions", diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py index 6bc62a86..7e2cca21 100644 --- a/tests/browser/workspace_tagging_fixture.py +++ b/tests/browser/workspace_tagging_fixture.py @@ -56,6 +56,8 @@ def _valid_missing(value) -> bool: # Unlinked copies enough to virtualize the Images grid (it renders all up to 120). MAX_EXTRA_IMAGES = 160 _TABLES = ( + "image_collection_items", + "image_collections", "saved_views", "image_favorites", "media_review_decisions", diff --git a/tests/browser/workspace_visual_fixture.py b/tests/browser/workspace_visual_fixture.py index 6fbe1fcc..728e509d 100644 --- a/tests/browser/workspace_visual_fixture.py +++ b/tests/browser/workspace_visual_fixture.py @@ -112,6 +112,8 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None: """Replace only the owned browser harness records and media roots.""" with api.db.model.get_connection() as conn: for table in ( + "image_collection_items", + "image_collections", "saved_views", "image_favorites", "media_review_decisions", diff --git a/tests/browser/workspace_vocabulary_fixture.py b/tests/browser/workspace_vocabulary_fixture.py index b1f7e301..d3e22b7b 100644 --- a/tests/browser/workspace_vocabulary_fixture.py +++ b/tests/browser/workspace_vocabulary_fixture.py @@ -83,6 +83,8 @@ UNUSED_TAG = "unused_vocabulary" MAX_EXTRA_TAGS = 300 MAX_EXTRA_CATEGORIES = 200 _TABLES = ( + "image_collection_items", + "image_collections", "saved_views", "image_favorites", "media_review_decisions", diff --git a/tests/js/workspace-collections.test.js b/tests/js/workspace-collections.test.js new file mode 100644 index 00000000..d73ad554 --- /dev/null +++ b/tests/js/workspace-collections.test.js @@ -0,0 +1,313 @@ +// Collections (#331 D136–D140): the client store, the route hash, the names, every user-facing string and the +// mapping of the routes' error codes. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const source = import('../../web/js/workspace/collections.mjs'); + +const FILE = Object.freeze({ root_index: 0, root_scope: 'a'.repeat(64), path: 'grids/one.png' }); + +function apiError(status, code) { + return Object.assign(new Error(code || `HTTP ${status}`), { status, code: code || null }); +} + +function fakeApi() { + const calls = []; + const pending = []; + const api = { + calls, + get(url, options) { + calls.push(['GET', url, options?.cache]); + if (url.includes('/membership?')) return api.membershipResult ?? Promise.resolve({ success: true, collection_ids: [2] }); + return new Promise((resolve, reject) => pending.push({ resolve, reject })); + }, + post(url, body) { + calls.push(['POST', url, body]); + return api.postResult ?? Promise.resolve({ success: true, collection: { id: 5, name: 'Grids' }, created: true, + added: body.items.length, already: 0, not_found: 0 }); + }, + patch(url, body) { + calls.push(['PATCH', url, body]); + return api.patchResult ?? Promise.resolve({ success: true, collection: { id: 2, name: body.name } }); + }, + del(url, options) { + calls.push(['DELETE', url, options?.body]); + return api.deleteResult ?? Promise.resolve(options?.body + ? { success: true, collection: { id: options.body.collection_id, name: 'Portraits' }, removed: options.body.items.length } + : { success: true, deleted: true }); + }, + }; + return { api, pending }; +} + +const LISTED = { success: true, collections: [ + { id: 4, name: 'Experiments', count: 8, missing: 0 }, + { id: 2, name: 'Landscapes', count: 12, missing: 1 }, +] }; + +/** A store whose first read answered with `data`; the test's own timer seam replaces the 300 ms count timer. */ +async function readyStore(data = LISTED) { + const { createCollections } = await source; + const { api, pending } = fakeApi(); + const timers = []; + const controller = new AbortController(); + const store = createCollections(api, controller.signal, { + setTimer: (run, delay) => { timers.push({ run, delay, cleared: false }); return timers.length; }, + clearTimer: id => { if (timers[id - 1]) timers[id - 1].cleared = true; }, + }); + store.load(); + pending.shift().resolve(data); + await store.settled(); + return { store, api, pending, timers, controller }; +} + +test('the route hash, the name rule and the limits', async () => { + const { collectionHash, normalizeCollectionName, MAX_COLLECTION_NAME_LENGTH, MAX_COLLECTION_ITEMS, + COLLECTION_COUNT_DELAY_MS } = await source; + assert.equal(collectionHash(12), '#/collection/12'); + assert.throws(() => collectionHash(0), TypeError); + assert.throws(() => collectionHash('3'), TypeError); + assert.equal(MAX_COLLECTION_NAME_LENGTH, 100); + assert.equal(MAX_COLLECTION_ITEMS, 500); + assert.equal(COLLECTION_COUNT_DELAY_MS, 300); + // Trimmed, each run of whitespace one space, 1 to 100 characters, no control characters (design §1). + assert.equal(normalizeCollectionName(' LoRa testing   grids '), 'LoRa testing grids'); + assert.equal(normalizeCollectionName('x'.repeat(100)), 'x'.repeat(100)); + for (const bad of ['', ' ', 'x'.repeat(101), 'bad\u0007name', 7, null, undefined]) { + assert.equal(normalizeCollectionName(bad), null, String(bad)); + } + // A tab or newline is whitespace, collapsed rather than refused. + assert.equal(normalizeCollectionName('a\tb\nc'), 'a b c'); +}); + +test('the listing keeps usable rows in the server order, with counts only when they are counts', async () => { + const { parseCollections, parseCollection } = await source; + assert.deepEqual(parseCollections({ success: true, collections: [ + { id: 3, name: 'b', count: 2, missing: 0 }, + { id: 0, name: 'bad id' }, + { id: 1, name: ' ', count: 1, missing: 0 }, + { id: 7, name: 'A', count: null, missing: null }, + { id: 9, name: 'c', count: -1, missing: 1.5 }, + ] }), [ + { id: 3, name: 'b', count: 2, missing: 0 }, + { id: 7, name: 'A', count: null, missing: null }, + { id: 9, name: 'c', count: null, missing: null }, + ]); + assert.equal(parseCollection(null), null); + assert.throws(() => parseCollections({ success: true }), TypeError); +}); + +test('every user-facing string of the design (§7.4, §8)', async () => { + const c = await source; + assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 0 }), 'Added 1 image to “X”.'); + assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 3, already: 0, notFound: 0 }), 'Added 3 images to “X”.'); + assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 1, notFound: 0 }), + 'Added 1 image to “X”. 1 was already in it.'); + assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 2, already: 3, notFound: 1 }), + 'Added 2 images to “X”. 3 were already in it. 1 could not be found.'); + assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 1, already: 0, notFound: 2 }), + 'Added 1 image to “X”. 2 could not be found.'); + assert.equal(c.addedMessage({ collection: { name: 'X' }, added: 0, already: 4, notFound: 0 }), + 'All selected images were already in “X”.'); + assert.equal(c.FILES_NOT_FOUND_TOAST, 'None of the selected images could be found. Refresh the images and try again.'); + assert.equal(c.SOURCE_CHANGED_TOAST, 'The sources changed. Refresh the images and try again.'); + assert.equal(c.ADD_FAILED_TOAST, 'The images could not be added. Try again.'); + assert.equal(c.removedMessage('X', 1), 'Removed 1 image from “X”.'); + assert.equal(c.removedMessage('X', 4), 'Removed 4 images from “X”.'); + assert.equal(c.REMOVE_FAILED_TOAST, 'The images could not be removed. Try again.'); + assert.equal(c.COLLECTION_GONE_TOAST, 'That collection no longer exists.'); + assert.deepEqual([c.COLLECTION_NOUNS.title, c.COLLECTION_NOUNS.label, c.COLLECTION_NOUNS.deleteTitle], + ['Edit collection', 'Collection name', 'Delete collection']); + assert.equal(c.COLLECTION_NOUNS.renamed, 'Collection renamed.'); + assert.equal(c.RENAMED_TOAST, 'Collection renamed.'); + assert.equal(c.nameTakenMessage('LoRa'), 'A collection named “LoRa” already exists.'); + assert.equal(c.RENAME_FAILED_TOAST, 'The collection could not be renamed. Try again.'); + // The stored count: listed members plus missing ones. + assert.equal(c.deleteQuestion({ name: 'X', count: 1, missing: 0 }), + 'Delete “X”? Its 1 image stays where it is; only the collection is removed.'); + assert.equal(c.deleteQuestion({ name: 'X', count: 3, missing: 1 }), + 'Delete “X”? Its 4 images stay where they are; only the collection is removed.'); + assert.equal(c.deleteQuestion({ name: 'X', count: null, missing: null }), + 'Delete “X”? Its images stay where they are; only the collection is removed.'); + assert.equal(c.COLLECTION_NOUNS.deleteText({ name: 'X', count: 2, missing: 0 }), c.deleteQuestion({ name: 'X', count: 2, missing: 0 })); + assert.equal(c.DELETED_TOAST, 'Collection deleted.'); + assert.equal(c.DELETE_FAILED_TOAST, 'The collection could not be deleted. Try again.'); + assert.equal(c.DELETED_PAGE_TEXT, 'This collection no longer exists.'); + assert.equal(c.OPEN_IMAGES_LINK, 'Open Images'); + assert.equal(c.VIEW_COLLECTION_DELETED, 'The collection this view uses was deleted.'); + assert.equal(c.LIST_FAILED_TEXT, 'Collections could not be loaded. Your saved data has not changed.'); + assert.equal(c.missingNote(1), '1 image in this collection is not in your current sources.'); + assert.equal(c.missingNote(5), '5 images in this collection are not in your current sources.'); + for (const none of [0, null, undefined, -1, 1.5]) assert.equal(c.missingNote(none), ''); + assert.equal(c.TOO_MANY_TOAST, 'Select at most 500 images.'); + assert.deepEqual([c.ADD_DIALOG_TITLE, c.ADD_DIALOG_LABEL, c.ADD_DIALOG_ACTION], + ['Add selected images to collection', 'Collection name', 'Add']); + assert.equal(c.COLLECTIONS_UPDATED_TOAST, 'Collections updated.'); + assert.equal(c.UPDATE_FAILED_TEXT, 'The collections could not be updated. Try again.'); + assert.equal(c.MEMBERSHIP_LOADING_TEXT, 'Loading collections…'); + assert.equal(c.MEMBERSHIP_FAILED_TEXT, 'Collections could not be loaded.'); + assert.equal(c.NO_COLLECTIONS_TEXT, 'No collections yet. Name one below.'); +}); + +test('a write failure maps the routes\' error codes to their strings', async () => { + const { writeFailure, renameFailure, isCollectionGone, COLLECTION_GONE_TOAST, SOURCE_CHANGED_TOAST, + FILES_NOT_FOUND_TOAST, ADD_FAILED_TOAST, RENAME_FAILED_TOAST } = await source; + assert.equal(writeFailure(apiError(404, 'collection-not-found'), ADD_FAILED_TOAST), COLLECTION_GONE_TOAST); + assert.equal(writeFailure(apiError(409, 'source-changed'), ADD_FAILED_TOAST), SOURCE_CHANGED_TOAST); + assert.equal(writeFailure(apiError(404, 'files-not-found'), ADD_FAILED_TOAST), FILES_NOT_FOUND_TOAST); + for (const other of [apiError(500), apiError(400), apiError(404), new TypeError('x'), null]) { + assert.equal(writeFailure(other, ADD_FAILED_TOAST), ADD_FAILED_TOAST); + } + assert.equal(isCollectionGone(apiError(404, 'collection-not-found')), true); + assert.equal(isCollectionGone(apiError(404)), false); + // The Edit dialog: a taken name stays in the dialog; a gone collection closes it; anything else keeps it open. + assert.deepEqual(renameFailure('LoRa')(apiError(409, 'name-taken')), { field: 'A collection named “LoRa” already exists.' }); + assert.deepEqual(renameFailure('LoRa')(apiError(404, 'collection-not-found')), { toast: COLLECTION_GONE_TOAST, close: true }); + assert.deepEqual(renameFailure('LoRa')(apiError(500)), { toast: RENAME_FAILED_TOAST }); +}); + +test('the store reports loads, keeps the last list after a failed read, and a newer read supersedes', async () => { + const { createCollections, COLLECTIONS_URL } = await source; + const { api, pending } = fakeApi(); + const store = createCollections(api); + assert.equal(store.status, 'loading'); + const seen = []; + store.subscribe(next => seen.push([next.status, next.collections.map(item => item.id)])); + store.load(); + const settled = store.settled(); + pending[0].resolve(LISTED); + assert.equal((await settled).status, 'ready'); + assert.deepEqual(api.calls[0], ['GET', COLLECTIONS_URL, 'no-store']); + assert.deepEqual(store.find(2), { id: 2, name: 'Landscapes', count: 12, missing: 1 }); + assert.equal(store.find(3), null); + assert.deepEqual(store.collections.map(item => item.name), ['Experiments', 'Landscapes']); + + store.load(); + pending[1].reject(new Error('offline')); + await store.settled(); + assert.equal(store.status, 'error'); + assert.deepEqual(store.collections.map(item => item.id), [4, 2]); + + store.load(); + store.load(); + pending[3].resolve({ success: true, collections: [{ id: 8, name: 'New', count: 0, missing: 0 }] }); + pending[2].resolve(LISTED); + await store.settled(); + assert.deepEqual(store.collections.map(item => item.id), [8]); + assert.deepEqual(seen.at(-1), ['ready', [8]]); + // A malformed listing is a failed read. + store.load(); + pending[4].resolve({ success: true }); + await store.settled(); + assert.equal(store.status, 'error'); +}); + +test('adding by name or id sends only the file identity and re-reads the counts 300 ms after the last change', async () => { + const { store, api, pending, timers } = await readyStore(); + const { COLLECTION_ITEMS_URL } = await source; + const result = await store.add({ name: ' Grids ' }, [{ ...FILE, title: 'ignored', favorite: true }]); + assert.deepEqual(api.calls.at(-1), ['POST', COLLECTION_ITEMS_URL, { name: 'Grids', items: [FILE] }]); + assert.deepEqual(result, { collection: { id: 5, name: 'Grids' }, created: true, added: 1, already: 0, notFound: 0 }); + // The new collection is listed at once (its count arrives with the re-read), in name order. + assert.deepEqual(store.collections.map(item => [item.id, item.count]), [[4, 8], [5, null], [2, 12]]); + api.postResult = Promise.resolve({ success: true, collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, + already: 1, not_found: 1 }); + const again = await store.add({ collectionId: 2 }, [FILE, { ...FILE, path: 'b.png' }]); + assert.deepEqual(api.calls.at(-1)[2], { collection_id: 2, items: [FILE, { ...FILE, path: 'b.png' }] }); + assert.deepEqual(again, { collection: { id: 2, name: 'Landscapes' }, created: false, added: 0, already: 1, notFound: 1 }); + // One trailing re-read for the run of writes. + assert.deepEqual(timers.map(timer => [timer.delay, timer.cleared]), [[300, true], [300, false]]); + const reads = api.calls.filter(call => call[0] === 'GET').length; + timers.at(-1).run(); + assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1); + pending.shift().resolve(LISTED); + await store.settled(); +}); + +test('a write is refused before sending when its target or files are not valid', async () => { + const { store, api } = await readyStore(); + const sent = api.calls.length; + const many = Array.from({ length: 501 }, (_, index) => ({ ...FILE, path: `${index}.png` })); + for (const [target, items] of [ + [{ name: '' }, [FILE]], [{ name: 'x'.repeat(101) }, [FILE]], [{ collectionId: 0 }, [FILE]], [{}, [FILE]], + [{ name: 'a', collectionId: 2 }, [FILE]], [{ collectionId: 2 }, []], [{ collectionId: 2 }, many], + [{ collectionId: 2 }, [{ ...FILE, root_index: -1 }]], [{ collectionId: 2 }, [{ ...FILE, path: '' }]], + [{ collectionId: 2 }, [{ ...FILE, root_scope: 3 }]], [{ collectionId: 2 }, 'a.png'], + ]) { + await assert.rejects(store.add(target, items), TypeError); + } + await assert.rejects(store.remove(0, [FILE]), TypeError); + await assert.rejects(store.remove(2, []), TypeError); + await assert.rejects(store.rename(2, ' '), TypeError); + await assert.rejects(store.delete(-1), TypeError); + await assert.rejects(store.membership({ root_index: 'x', root_scope: 'a', path: 'p' }), TypeError); + assert.equal(api.calls.length, sent); +}); + +test('a write to a collection that is gone re-reads the list at once and rejects with the error', async () => { + const { store, api, pending } = await readyStore(); + const { isCollectionGone } = await source; + api.postResult = Promise.reject(apiError(404, 'collection-not-found')); + const reads = api.calls.filter(call => call[0] === 'GET').length; + await assert.rejects(store.add({ collectionId: 2 }, [FILE]), error => isCollectionGone(error)); + assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1); + pending.shift().resolve({ success: true, collections: [LISTED.collections[0]] }); + await store.settled(); + assert.equal(store.find(2), null); + // Another failure re-reads nothing. + api.deleteResult = Promise.reject(apiError(500)); + await assert.rejects(store.remove(4, [FILE]), error => error.status === 500); + assert.equal(api.calls.filter(call => call[0] === 'GET').length, reads + 1); +}); + +test('remove sends the collection and files; membership reads one file\'s collections', async () => { + const { store, api, timers } = await readyStore(); + const { COLLECTION_ITEMS_URL, COLLECTION_MEMBERSHIP_URL } = await source; + assert.deepEqual(await store.remove(2, [FILE]), { collection: { id: 2, name: 'Portraits' }, removed: 1 }); + assert.deepEqual(api.calls.at(-1), ['DELETE', COLLECTION_ITEMS_URL, { collection_id: 2, items: [FILE] }]); + assert.equal(timers.length, 1); + assert.deepEqual(await store.membership(FILE), [2]); + const [method, url] = api.calls.at(-1); + assert.equal(method, 'GET'); + const query = new URL(url, 'http://local'); + assert.equal(query.pathname, COLLECTION_MEMBERSHIP_URL); + assert.deepEqual(Object.fromEntries(query.searchParams), { root_index: '0', root_scope: FILE.root_scope, path: FILE.path }); + api.membershipResult = Promise.resolve({ success: true, collection_ids: [1, 'x'] }); + await assert.rejects(store.membership(FILE), TypeError); +}); + +test('rename and delete change the listed row at once and re-read the counts', async () => { + const { store, api, timers } = await readyStore(); + const { COLLECTIONS_URL } = await source; + const renamed = await store.rename(2, ' A grids '); + assert.deepEqual(api.calls.at(-1), ['PATCH', `${COLLECTIONS_URL}/2`, { name: 'A grids' }]); + assert.deepEqual(renamed, { id: 2, name: 'A grids' }); + // The row keeps its counts and moves to its new place in name order. + assert.deepEqual(store.collections.map(item => [item.id, item.name, item.count]), [[2, 'A grids', 12], [4, 'Experiments', 8]]); + assert.equal(await store.delete(4), 4); + assert.deepEqual(api.calls.at(-1), ['DELETE', `${COLLECTIONS_URL}/4`, undefined]); + assert.deepEqual(store.collections.map(item => item.id), [2]); + // Already gone (another tab): the outcome the user asked for. + api.deleteResult = Promise.reject(apiError(404, 'collection-not-found')); + assert.equal(await store.delete(2), 2); + assert.deepEqual(store.collections, []); + api.patchResult = Promise.reject(apiError(409, 'name-taken')); + await assert.rejects(store.rename(9, 'Taken'), error => error.code === 'name-taken'); + assert.equal(timers.filter(timer => !timer.cleared).length, 1); +}); + +test('a disposed store stops its count timer, drops a read in flight and reads no more', async () => { + const { store, timers, controller, api, pending } = await readyStore(); + await store.rename(2, 'Kept'); + const seen = []; + store.subscribe(next => seen.push(next)); + store.load(); + controller.abort(); + assert.equal(timers.at(-1).cleared, true); + pending.shift().resolve(LISTED); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(seen.filter(next => next.status === 'ready').length, 0); + const sent = api.calls.length; + await store.load(); + assert.equal(api.calls.length, sent); +}); diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index db74a8b6..84b0fd1b 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -141,6 +141,22 @@ test('the Favorites page asks the listing for favorites only (#249 D51)', async for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/); }); +test('a collection page asks the listing for that collection only (#331 D137)', async () => { + const {createImageStream}=await source;let query; + const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}}); + await stream.reset({collection:'12',q:'fog'}); + assert.equal(query.get('collection'),'12');assert.equal(query.get('q'),'fog');assert.equal(query.has('favorites'),false); + await stream.reset({collection:7}); + assert.equal(query.get('collection'),'7'); + for(const collection of ['',null,undefined]) { + await stream.reset({collection}); + assert.equal(query.has('collection'),false); + } + for(const collection of ['0','07','x',-1,1.5,'1234567890',true])await assert.rejects(stream.reset({collection}),/Invalid collection filter/); + // Favorites and a collection are never both a page's filter. + await assert.rejects(stream.reset({collection:'3',favorites:true}),/Invalid collection filter/); +}); + test('restored browsing state resumes its cursor and filters without an initial request', async () => { const {createImageStream}=await source; const calls=[]; @@ -517,5 +533,7 @@ test('listingFilter is the listing query as an object, without paging (#392)', a assert.deepEqual(listingFilter({q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:[' x ','y'], media:'video', sort:'oldest', favorites:true}), {q:'cat', root:'1', subfolder:'a/b', category:'Animals', tags:'x,y', media:'video', sort:'oldest', favorites:'1'}); assert.deepEqual(listingFilter({root:'all', q:'', tags:[], media:'all'}), {sort:'newest'}); + // #331: "Select all N matching" on a collection page acts on that collection's listing. + assert.deepEqual(listingFilter({collection:'4', q:'cat'}), {q:'cat', sort:'newest', collection:'4'}); assert.throws(() => listingFilter({sort:'sideways'}), TypeError); }); diff --git a/tests/js/workspace-saved-views.test.js b/tests/js/workspace-saved-views.test.js index 79f530aa..46d2fe06 100644 --- a/tests/js/workspace-saved-views.test.js +++ b/tests/js/workspace-saved-views.test.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict'); const source = import('../../web/js/workspace/saved-views.mjs'); -const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false }; +const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false, collection: '' }; const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] }; test('a saved state always holds every page key and keeps only values its page accepts', async () => { @@ -12,8 +12,17 @@ test('a saved state always holds every page key and keeps only values its page a assert.deepEqual(SAVED_VIEW_PAGES, ['images', 'library']); assert.deepEqual(normalizeViewState('images', {}), IMAGE_DEFAULTS); assert.deepEqual(normalizeViewState('library', null), LIBRARY_DEFAULTS); - const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true }; + const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true, collection: '' }; assert.deepEqual(normalizeViewState('images', full), full); + // #331 D140: a collection id is '' or 1 to 9 digits without a leading zero; an old view without the key reads ''. + for (const collection of ['1', '12', '999999999']) { + assert.equal(normalizeViewState('images', { collection }).collection, collection); + } + for (const collection of ['0', 'x', 12, '012', '1234567890', null]) { + assert.equal(normalizeViewState('images', { collection }).collection, ''); + } + // Favorites and a collection are never both a view's filter: the collection wins, as the listing's URL does. + assert.deepEqual(normalizeViewState('images', { ...full, collection: '4' }), { ...full, favorites: false, collection: '4' }); // D51: favorites is a boolean; anything else is the whole listing. for (const favorites of ['true', 1, null]) assert.equal(normalizeViewState('images', { ...full, favorites }).favorites, false); // Each rejected value falls back on its own; the rest of the view still applies. @@ -63,11 +72,17 @@ 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 }; + const filters = { q: 'castle', category: 'Portraits', sort: 'rating', 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 }); + assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', 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 }); + 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 ''. + const inCollection = { ...filters, favorites: false, collection: '7' }; + assert.equal(imagesViewState(inCollection).collection, '7'); + assert.deepEqual(imagesFilters(imagesViewState(inCollection)), inCollection); + assert.equal(imagesViewState({ ...filters, collection: undefined }).collection, ''); + assert.equal(imagesViewState({ ...filters, collection: 7 }).collection, '7'); assert.equal(imagesViewState({ ...filters, favorites: undefined }).favorites, false); const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] }; assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'] }); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index b646dc5a..526cbe70 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -311,3 +311,29 @@ test('Needs review is a native SAVED VIEWS route titled Review, with its own cou assert.deepEqual(calls, ['/prompt_manager/review/items?limit=1']); assert.deepEqual([...counts], [['review', 4]]); }); + +// Collections (#331 D137): `#/collection/` opens Images filtered to one collection; its breadcrumb is "Collection". +test('collection links resolve to their id, title Collection, and re-render only when the id changes', async () => { + const {resolveRoute,mountRouter,isHandoff,routeTitle,routes,COLLECTION_ROUTE}=await source; + assert.deepEqual(COLLECTION_ROUTE,{id:'collection',label:'Collection'}); + assert.ok(Object.isFrozen(COLLECTION_ROUTE)); + const route=resolveRoute('#/collection/7'); + assert.deepEqual([route.id,route.collectionId,route.hash,routeTitle(route)],['collection',7,'#/collection/7','Collection']); + assert.equal(isHandoff(route),false); + assert.equal(resolveRoute('#/collection/999999999').collectionId,999999999); + assert.equal(routes.some(item=>item.id==='collection'),false,'a collection is not an Overview card or a WORKSPACE link'); + // The listing's and routes' id rule: 1 to 9 digits without a leading zero. + for(const hash of ['#/collection','#/collection/','#/collection/0','#/collection/07','#/collection/-2','#/collection/1.5', + '#/collection/1234567890','#/collection/3/x','#/collection/abc','#/collections/3']) { + assert.equal(resolveRoute(hash).id,'images',hash); + } + const win=new EventTarget();win.location={hash:'#/collection/3'}; + win.history={state:null,replaceState(state,unused,hash){win.location.hash=hash;}}; + const seen=[];const dispose=mountRouter(win,next=>seen.push([next.id,next.collectionId])); + win.dispatchEvent(new Event('hashchange')); + win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange')); + win.location.hash='#/saved/4';win.dispatchEvent(new Event('hashchange')); + win.location.hash='#/collection/4';win.dispatchEvent(new Event('hashchange')); + assert.deepEqual(seen,[['collection',3],['collection',4],['saved',undefined],['collection',4]]); + dispose(); +}); diff --git a/tests/test_saved_views.py b/tests/test_saved_views.py index a92d09a2..662e7573 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -40,6 +40,7 @@ IMAGES_STATE = { "media": "all", "tags": ["fluffy"], "favorites": False, + "collection": "", } LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []} IMAGES_DEFAULTS = { @@ -51,6 +52,7 @@ IMAGES_DEFAULTS = { "media": "all", "tags": [], "favorites": False, + "collection": "", } LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []} # A row saved by an older build: no favorites key, a retired key, a sort @@ -230,6 +232,48 @@ class TestSavedViewsApi(ImageAPITestCase): _, listed = await self.send("GET") self.assertEqual(len(listed["views"]), 20) + async def test_write_requires_the_collection_key(self): + # #331 D140: a view saved on a collection page keeps the collection id. + without = {k: v for k, v in IMAGES_STATE.items() if k != "collection"} + good = {"page": "images", "name": "Grid", "state": IMAGES_STATE} + cases = [ + {**good, "state": without}, + {**good, "state": {**IMAGES_STATE, "collection": "0"}}, + {**good, "state": {**IMAGES_STATE, "collection": "x"}}, + {**good, "state": {**IMAGES_STATE, "collection": 12}}, + {**good, "state": {**IMAGES_STATE, "collection": "012"}}, + {**good, "state": {**IMAGES_STATE, "collection": "1" * 10}}, + {**good, "state": {**IMAGES_STATE, "collection": None}}, + # Favorites and a collection are never both a view's filter. + { + **good, + "state": {**IMAGES_STATE, "favorites": True, "collection": "3"}, + }, + ] + for body in cases: + with self.subTest(body=body): + response, data = await self.send("POST", body=body) + self.assertEqual(response.status, 400, data) + self.assertFalse(data["success"]) + for collection in ("1", "12", "9" * 9): + view = await self.create(state={"collection": collection}) + self.assertEqual(view["state"]["collection"], collection) + self.assertIs(view["state"]["favorites"], False) + + async def test_an_old_row_without_a_collection_reads_empty(self): + with self.api.db.model.get_connection() as conn: + conn.execute( + f"INSERT INTO {TABLE} (page, name, state, position, created_at, " + "updated_at) VALUES ('images', 'Old', ?, 0, 'x', 'x')", + (json.dumps({"q": "cat", "favorites": True}),), + ) + _, data = await self.send("GET") + (view,) = data["views"] + self.assertEqual( + view["state"], {**IMAGES_DEFAULTS, "q": "cat", "favorites": True} + ) + self.assertEqual(view["state"]["collection"], "") + async def test_rename_and_delete_validate_id_and_body(self): view = await self.create() for path in ("/0", "/-1", "/abc", "/1.5", f"/{2**63}"): @@ -618,6 +662,17 @@ class TestSavedViewsSchema(unittest.TestCase): ), {**IMAGES_DEFAULTS, "folder": "kept"}, ) + # A stored row with both filters (never written by this build) opens the + # collection, as a listing URL with both does. + self.assertEqual( + normalize_state("images", {"favorites": True, "collection": "4"}), + {**IMAGES_DEFAULTS, "collection": "4"}, + ) + for bad in ("0", "x", 12, "012"): + with self.subTest(collection=bad): + self.assertEqual( + normalize_state("images", {"collection": bad}), IMAGES_DEFAULTS + ) self.assertEqual(normalize_state("history", {}), {}) def test_validation_errors_are_saved_view_invalid(self): diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index dd070d4b..7537b202 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -203,6 +203,12 @@ "functions": 98, "statements": 96 }, + "workspace/collections.mjs": { + "lines": 98, + "branches": 95, + "functions": 95, + "statements": 98 + }, "workspace/file-inspector.mjs": { "lines": 96, "branches": 93, diff --git a/web/js/workspace/collections.mjs b/web/js/workspace/collections.mjs new file mode 100644 index 00000000..827b3c16 --- /dev/null +++ b/web/js/workspace/collections.mjs @@ -0,0 +1,314 @@ +/** + * Collections (#331): named, hand-picked groups of images, persisted through `/prompt_manager/collections*`. + * + * - D136: the sidebar COLLECTIONS group (the concept's `#collections-nav`, index.html 28–31, app.js 336–339) lists + * each collection as `#/collection/` with its listed-member count, without the concept's "PROPOSED" badge. + * - D137: a collection page is Images filtered to the collection; its Edit renames or deletes it through the saved + * views' Edit dialog (D51) with collection nouns. + * - This module holds the client store (`createCollections`), the route hash, the name rule and every user-facing + * string of the design (§7.4, §8), so the selection bar, the inspector and the shell say the same things. + */ +import { noun } from './plural.mjs'; +import { openEditDialog } from './saved-views.mjs'; + +export const COLLECTIONS_URL = '/prompt_manager/collections'; +export const COLLECTION_ITEMS_URL = `${COLLECTIONS_URL}/items`; +export const COLLECTION_MEMBERSHIP_URL = `${COLLECTIONS_URL}/membership`; +export const MAX_COLLECTION_NAME_LENGTH = 100; +// The routes' cap on one add or remove (the bulk tagging cap, MAX_BULK_IMAGES). +export const MAX_COLLECTION_ITEMS = 500; +// Counts are re-read once after a run of confirmed changes settles, as the Favorites count (#249 M1). +export const COLLECTION_COUNT_DELAY_MS = 300; + +// Strings (design §7.4 and §8). +export const ADD_DIALOG_TITLE = 'Add selected images to collection'; +export const ADD_DIALOG_LABEL = 'Collection name'; +export const ADD_DIALOG_ACTION = 'Add'; +export const TOO_MANY_TOAST = `Select at most ${MAX_COLLECTION_ITEMS} images.`; +export const FILES_NOT_FOUND_TOAST = 'None of the selected images could be found. Refresh the images and try again.'; +export const SOURCE_CHANGED_TOAST = 'The sources changed. Refresh the images and try again.'; +export const ADD_FAILED_TOAST = 'The images could not be added. Try again.'; +export const REMOVE_FAILED_TOAST = 'The images could not be removed. Try again.'; +export const COLLECTION_GONE_TOAST = 'That collection no longer exists.'; +export const RENAMED_TOAST = 'Collection renamed.'; +export const RENAME_FAILED_TOAST = 'The collection could not be renamed. Try again.'; +export const DELETED_TOAST = 'Collection deleted.'; +export const DELETE_FAILED_TOAST = 'The collection could not be deleted. Try again.'; +export const DELETED_PAGE_TEXT = 'This collection no longer exists.'; +export const OPEN_IMAGES_LINK = 'Open Images'; +export const VIEW_COLLECTION_DELETED = 'The collection this view uses was deleted.'; +export const LIST_FAILED_TEXT = 'Collections could not be loaded. Your saved data has not changed.'; +export const COLLECTIONS_UPDATED_TOAST = 'Collections updated.'; +export const UPDATE_FAILED_TEXT = 'The collections could not be updated. Try again.'; +export const MEMBERSHIP_LOADING_TEXT = 'Loading collections…'; +export const MEMBERSHIP_FAILED_TEXT = 'Collections could not be loaded.'; +export const NO_COLLECTIONS_TEXT = 'No collections yet. Name one below.'; + +const CONTROL = /[\u0000-\u001f\u007f]/; +const isCount = value => Number.isSafeInteger(value) && value >= 0; +const isId = value => Number.isSafeInteger(value) && value >= 1 && value <= 999_999_999; + +/** The route of one collection. */ +export function collectionHash(id) { + if (!isId(id)) throw new TypeError('Invalid collection id'); + return `#/collection/${id}`; +} + +/** + * A name as the database stores it (design §1): trimmed, each run of whitespace one space, 1 to 100 characters + * without control characters; null otherwise. Tabs and newlines are whitespace, so they collapse. + */ +export function normalizeCollectionName(value) { + if (typeof value !== 'string') return null; + const name = value.trim().replace(/\s+/g, ' '); + return name.length >= 1 && name.length <= MAX_COLLECTION_NAME_LENGTH && !CONTROL.test(name) ? name : null; +} + +/** One listed collection, or null when the row is not usable. `count` and `missing` are null unless counts. */ +export function parseCollection(row) { + if (!isId(row?.id) || typeof row.name !== 'string' || !row.name.trim()) return null; + const counted = isCount(row.count) && isCount(row.missing); + return { id: row.id, name: row.name, count: counted ? row.count : null, missing: counted ? row.missing : null }; +} + +/** The listing's collections in the server's order (name, then id), skipping unusable rows. */ +export function parseCollections(data) { + if (!Array.isArray(data?.collections)) throw new TypeError('Invalid collections'); + return data.collections.map(parseCollection).filter(Boolean); +} + +/** The sidebar's order while a re-read is pending: an approximation of the server's name key, then id. */ +const nameKey = name => name.normalize('NFC').toLowerCase().normalize('NFC'); +const ordered = list => [...list].sort((a, b) => { + const left = nameKey(a.name), right = nameKey(b.name); + return left < right ? -1 : left > right ? 1 : a.id - b.id; +}); + +/** "Added N images to “X”." with what was already in it and what could not be found. */ +export function addedMessage({ collection, added, already, notFound }) { + if (added === 0 && notFound === 0) return `All selected images were already in “${collection.name}”.`; + let text = `Added ${added} ${noun(added, 'image')} to “${collection.name}”.`; + if (already > 0) text += already === 1 ? ' 1 was already in it.' : ` ${already} were already in it.`; + if (notFound > 0) text += ` ${notFound} could not be found.`; + return text; +} + +export function removedMessage(name, removed) { + return `Removed ${removed} ${noun(removed, 'image')} from “${name}”.`; +} + +export function nameTakenMessage(name) { + return `A collection named “${name}” already exists.`; +} + +/** The Delete confirmation, with the stored count (listed plus missing) when the counts are known. */ +export function deleteQuestion({ name, count, missing }) { + if (!isCount(count) || !isCount(missing)) { + return `Delete “${name}”? Its images stay where they are; only the collection is removed.`; + } + const stored = count + missing; + return stored === 1 + ? `Delete “${name}”? Its 1 image stays where it is; only the collection is removed.` + : `Delete “${name}”? Its ${stored} images stay where they are; only the collection is removed.`; +} + +/** The collection page's note about members its sources do not list now (MC3); empty when there are none. */ +export function missingNote(missing) { + if (!isCount(missing) || missing === 0) return ''; + return missing === 1 + ? '1 image in this collection is not in your current sources.' + : `${missing} images in this collection are not in your current sources.`; +} + +export const isCollectionGone = error => error?.code === 'collection-not-found'; + +/** A failed add or remove's toast: the routes' error codes, else `fallback`. */ +export function writeFailure(error, fallback) { + if (isCollectionGone(error)) return COLLECTION_GONE_TOAST; + if (error?.code === 'source-changed') return SOURCE_CHANGED_TOAST; + if (error?.code === 'files-not-found') return FILES_NOT_FOUND_TOAST; + return fallback; +} + +/** The Edit dialog's mapping of a failed rename of `name` (openEditDialog `renameError`). */ +export function renameFailure(name) { + return error => { + if (error?.code === 'name-taken') return { field: nameTakenMessage(name) }; + if (isCollectionGone(error)) return { toast: COLLECTION_GONE_TOAST, close: true }; + return { toast: RENAME_FAILED_TOAST }; + }; +} + +/** The collection nouns of the shared Edit dialog (D51, D137). */ +export const COLLECTION_NOUNS = Object.freeze({ + title: 'Edit collection', label: 'Collection name', deleteTitle: 'Delete collection', deleteText: deleteQuestion, + renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST, +}); + +/** + * D137: the open collection's Edit. A taken name stays in the dialog under the field; a failed rename keeps it open; + * a collection deleted meanwhile closes it and runs `onGone()` after the toast. `onRename(name)` sends the normalized + * name; a delete's 404 counts as deleted (the store's `delete`). + */ +export function openEditCollectionDialog({ scope, trigger, collection, onRename, onDelete, onGone = () => {} }) { + let attempted = ''; + const mapping = error => { + const outcome = renameFailure(attempted)(error); + if (outcome.close) queueMicrotask(onGone); + return outcome; + }; + openEditDialog({ scope, trigger, item: collection, nouns: COLLECTION_NOUNS, onDelete, renameError: mapping, + onRename: name => { + attempted = normalizeCollectionName(name) ?? name; + return onRename(attempted); + } }); +} + +function fileTargets(items) { + if (!Array.isArray(items) || items.length < 1 || items.length > MAX_COLLECTION_ITEMS) { + throw new TypeError('A collection change needs 1 to 500 files'); + } + return items.map(item => { + if (!Number.isSafeInteger(item?.root_index) || item.root_index < 0 || typeof item.root_scope !== 'string' || + !item.root_scope || typeof item.path !== 'string' || !item.path) { + throw new TypeError('Invalid collection file'); + } + return { root_index: item.root_index, root_scope: item.root_scope, path: item.path }; + }); +} + +function requireId(id) { + if (!isId(id)) throw new TypeError('Invalid collection id'); + return id; +} + +function requireName(name) { + const normalized = normalizeCollectionName(name); + if (normalized === null) throw new TypeError('Invalid collection name'); + return normalized; +} + +/** The `{id, name}` a write answered with. */ +function answered(data) { + const collection = parseCollection(data?.collection); + if (!collection) throw new TypeError('Invalid collection'); + return { id: collection.id, name: collection.name }; +} + +function countOf(value) { + if (!isCount(value)) throw new TypeError('Invalid collection count'); + return value; +} + +/** + * The shell's collection list. `load()` re-reads it (a newer read supersedes an older one), `subscribe(fn)` reports + * `{collections, status}` on every change, `settled()` resolves once no read is in flight; `status` is 'loading', + * 'ready' or 'error' and a failed read keeps the last list. `add`, `remove`, `rename` and `delete` change the listed + * rows at once and re-read the counts once, 300 ms after the last confirmed change; a write to a collection that is + * gone (404 `collection-not-found`) re-reads the list at once and rejects. `timers` is a test seam. + */ +export function createCollections(api, signal, { setTimer = setTimeout, clearTimer = clearTimeout } = {}) { + let collections = [], status = 'loading', request = null, timer = 0; + const listeners = new Set(); + let waiting = []; + const emit = () => { + const snapshot = { collections, status }; + for (const listener of listeners) listener(snapshot); + if (status !== 'loading') { const done = waiting; waiting = []; for (const resolve of done) resolve(snapshot); } + }; + const stopTimer = () => { if (timer) clearTimer(timer); timer = 0; }; + signal?.addEventListener('abort', () => { stopTimer(); request?.abort(); }, { once: true }); + function load() { + if (signal?.aborted) return Promise.resolve(); + request?.abort(); + const controller = new AbortController(); + request = controller; + status = 'loading'; + emit(); + return api.get(COLLECTIONS_URL, { signal: controller.signal, cache: 'no-store' }).then(data => { + if (request !== controller) return; + collections = parseCollections(data); + status = 'ready'; + }).catch(() => { + if (request !== controller) return; + status = 'error'; + }).finally(() => { + if (request !== controller || signal?.aborted) return; + request = null; + emit(); + }); + } + function changed(next) { + collections = ordered(next); + emit(); + stopTimer(); + timer = setTimer(() => { timer = 0; load(); }, COLLECTION_COUNT_DELAY_MS); + } + /** Send one write; a collection found gone meanwhile re-reads the list before the error reaches the caller. */ + async function send(write) { + try { + return await write(); + } catch (error) { + if (isCollectionGone(error)) load(); + throw error; + } + } + async function add(target, items) { + const byId = target?.collectionId !== undefined, byName = target?.name !== undefined; + if (byId === byName) throw new TypeError('A collection is named by its id or a name'); + const body = byId ? { collection_id: requireId(target.collectionId) } : { name: requireName(target.name) }; + body.items = fileTargets(items); + const data = await send(() => api.post(COLLECTION_ITEMS_URL, body, { signal })); + const result = { collection: answered(data), created: data.created === true, added: countOf(data.added), + already: countOf(data.already), notFound: countOf(data.not_found) }; + const { id, name } = result.collection; + changed(collections.some(item => item.id === id) + ? collections.map(item => item.id === id ? { ...item, name } : item) + : [...collections, { id, name, count: null, missing: null }]); + return result; + } + async function remove(id, items) { + const body = { collection_id: requireId(id), items: fileTargets(items) }; + const data = await send(() => api.del(COLLECTION_ITEMS_URL, { signal, body })); + const result = { collection: answered(data), removed: countOf(data.removed) }; + changed(collections); + return result; + } + async function membership(identity) { + const [file] = fileTargets([identity]); + const query = new URLSearchParams({ root_index: String(file.root_index), root_scope: file.root_scope, path: file.path }); + const data = await api.get(`${COLLECTION_MEMBERSHIP_URL}?${query}`, { signal, cache: 'no-store' }); + const ids = data?.collection_ids; + if (!Array.isArray(ids) || !ids.every(isId)) throw new TypeError('Invalid collection membership'); + return [...ids]; + } + async function rename(id, name) { + const url = `${COLLECTIONS_URL}/${requireId(id)}`; + const body = { name: requireName(name) }; + const renamed = answered(await send(() => api.patch(url, body, { signal }))); + changed(collections.map(item => item.id === renamed.id ? { ...item, name: renamed.name } : item)); + return renamed; + } + async function deleteCollection(id) { + const url = `${COLLECTIONS_URL}/${requireId(id)}`; + try { + await api.del(url, { signal }); + } catch (error) { + // Already gone (another tab deleted it): the outcome the user asked for. + if (error?.status !== 404) throw error; + } + changed(collections.filter(item => item.id !== id)); + return id; + } + return { + load, add, remove, membership, rename, delete: deleteCollection, + get collections() { return collections; }, + get status() { return status; }, + find: id => collections.find(item => item.id === id) ?? null, + settled: () => status === 'loading' ? new Promise(resolve => waiting.push(resolve)) : Promise.resolve({ collections, status }), + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index 99364b92..14992eb4 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -53,6 +53,14 @@ function queryFor(filters, batchSize) { const favorites = filters.favorites ?? false; if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter'); if (favorites) query.set('favorites', '1'); + // A collection page (#331 D137): the listing keeps only that collection's members; never together with favorites. + const collection = filters.collection ?? ''; + if (collection !== '') { + if (favorites || !/^[1-9]\d{0,8}$/.test(typeof collection === 'number' ? String(collection) : collection)) { + throw new TypeError('Invalid collection filter'); + } + query.set('collection', String(collection)); + } return query.toString(); } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index bfa9348e..469671e0 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -16,6 +16,7 @@ import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, resc import { openMetadataScan } from './activity-scan.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { imagesFilters, imagesViewState } from './saved-views.mjs'; +import { VIEW_COLLECTION_DELETED, isCollectionGone } from './collections.mjs'; import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs'; import { displayPreferences } from './preferences.mjs'; import { onImageTagsSaved } from './image-tagging.mjs'; @@ -64,6 +65,8 @@ const IMAGE_SORT_OPTIONS = Object.freeze([['newest', 'Recently added'], ['name', ['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.'; +// The concept's empty collection (app.js updateImages). +const NO_MATCHES = 'No matching images. Try another search or category.'; const RETRY_REFRESH = 'Refresh image collection to retry.'; /** One alert for whichever of the source discovery and the categories failed, so two failures announce once. */ function loadFailure(sourcesRetry, categoriesFailed) { @@ -108,10 +111,13 @@ export function selectedImage() { * caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus. * `savedState` opens a saved view (#249 D16) instead of the last visit; `onSaveView(trigger, state)` is Save view. * `favorites` is the Favorites page (#249 D51): only favorites are listed, and it neither restores nor replaces the - * Images last visit. + * Images last visit. `collection` (an id) is a collection page (#331 D137): only its members are listed, favorites is + * off, and it neither restores nor replaces the last visit either. A listing that answers `collection-not-found` calls + * `onCollectionMissing()` when given (the shell shows its deleted-collection page); otherwise (a saved view on a + * deleted collection, D140) the page lists nothing and says so. */ export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null, - favorites = false, onSaveView = () => {}, activity = null} = {}) { + favorites = false, collection: collectionId = null, onCollectionMissing = null, onSaveView = () => {}, activity = null} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -127,10 +133,13 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.cleanup(() => detailInspector?.dispose()); // A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its // category, which becomes the Images return state like any plain visit (as Browse does for the Library). - const handed = !embedded && !favorites && !savedState ? browseState : null; - if (!embedded && !favorites) browseState = null; + const imagesPage = !embedded && !favorites && collectionId === null; + const handed = imagesPage && !savedState ? browseState : null; + if (imagesPage) browseState = null; const saved = embedded || !savedState ? null : imagesFilters(savedState); - const prior = embedded || saved || favorites || handed ? null : lastVisit; + // The open collection: the route's, else a saved view's (D140); '' on every other page. + const openCollection = embedded ? '' : collectionId !== null ? String(collectionId) : saved?.collection || ''; + const prior = embedded || saved || favorites || openCollection || handed ? null : lastVisit; // A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6). const visit = prior?.stale ? null : prior; // The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no @@ -150,7 +159,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const itemIndexes = new Map(); const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved, ...(handed ? {category: handed.category} : {}), - favorites: favorites || saved?.favorites === true }; + // A collection and favorites are never both the filter (DC10). + favorites: !openCollection && (favorites || saved?.favorites === true), collection: openCollection }; filters.tags = [...filters.tags]; if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest'; // A saved Source or Folder is only listed once discovery confirms it still exists; until then nothing is fetched. @@ -161,7 +171,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // A Set category write still in flight may change what the saved collection shows: the return reloads instead. // A favorites-only collection or a saved view is not the plain Images collection, so it never becomes the // Images return state (#249 D51, and M2 for saved views). - if (!embedded && !filters.favorites && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null; + if (!embedded && !filters.favorites && !filters.collection && !saved) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null; cancelAnimationFrame(restoreFrame); }); main.classList.add('images-main'); @@ -256,7 +266,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // The concept's empty collection (app.js updateImages), beside the grid because the grid only holds positioned cards. // An embedded gallery shows one only when its page names the concept's copy (the history gallery, app.js a620). const showsEmpty = !embedded || typeof emptyText === 'string'; - const empty = el('div', embedded ? emptyText : 'No matching images. Try another search or category.', 'empty'); + const empty = el('div', embedded ? emptyText : NO_MATCHES, 'empty'); empty.hidden = true; // D47: the history page's shared gallery and dialogs are named by their own heading or title, so they have no caption. if (!embedded) collection.append(caption); @@ -478,8 +488,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!resizeFrame) resizeFrame = requestAnimationFrame(() => { resizeFrame = 0; updateLayout(); }); }); scope.cleanup(() => { resize.disconnect(); observer?.disconnect(); cancelAnimationFrame(resizeFrame); cancelAnimationFrame(frame); cancelAnimationFrame(loadFrame); }); + /** + * The listing refused the open collection: it was deleted. A saved view says so in place (MC6, D140); a collection + * page reports it (`onCollectionMissing`) once per failed read and meanwhile shows the listing's error and Retry, + * so a page whose shell cannot confirm the deletion is never left blank. + */ + const collectionGone = next => Boolean(filters.collection) && !onCollectionMissing && isCollectionGone(next.error); + let reportedMissing = null; function continuationText(next) { if (recovering) return 'Images paused until sources and folders are available.'; + if (collectionGone(next)) return ''; if (next.error) return 'Images could not be loaded. Retry or rescan the collection.'; if (next.loading && !next.loaded) return 'Loading images…'; if (next.hasMore) return `${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')} ${noun(next.total, 'image')} · more load as you scroll`; @@ -513,12 +531,20 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage const added = next.loaded - previous.loaded; showWorkspaceToast(`${added.toLocaleString('en-US')} more ${noun(added, 'image')} loaded. ${next.loaded.toLocaleString('en-US')} of ${next.total.toLocaleString('en-US')}.`); } + const gone = collectionGone(next); setText(message, continuationText(next)); - setAttr(message, 'role', next.error ? 'alert' : 'status'); - retry.hidden = !next.error; + setAttr(message, 'role', next.error && !gone ? 'alert' : 'status'); + retry.hidden = !next.error || gone; more.hidden = !next.hasMore || !!next.error; more.setAttribute('aria-disabled', String(next.loading || recovering)); - if (showsEmpty) empty.hidden = recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0; + if (onCollectionMissing && filters.collection && isCollectionGone(next.error) && reportedMissing !== next.error) { + reportedMissing = next.error; + queueMicrotask(() => { if (!scope.signal.aborted) onCollectionMissing(); }); + } + if (showsEmpty) { + setText(empty, gone ? VIEW_COLLECTION_DELETED : embedded ? emptyText : NO_MATCHES); + empty.hidden = !gone && (recovering || next.loading || !!next.error || next.hasMore || next.loaded > 0); + } if (focusAfterLoad !== null && !next.loading) settleLoadFocus(next); if (!selection && next.items.length) inspect(next.items[0]); scheduleLoad(); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 376a7740..018d181f 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -1,6 +1,6 @@ import '../api-client.js'; import { createButton, createScope } from '../shared/toolkit/core.js'; -import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, +import { routes, mountRouter, summarizeStats, routeTitle, loadNavCounts, navigateTo, COLLECTION_ROUTE, LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS, FAVORITE_COUNT_DELAY_MS, REVIEW_WRITE_COUNTS } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; import { forgetImagesVisit, mountImages, onFavoriteChange } from './images.mjs'; @@ -11,6 +11,8 @@ import { bindCanvasLink, canvasHref } from './topbar.mjs'; import { renderHeading, pageHeading } from './heading.mjs'; import { closeWorkspaceDialog } from './workspace-dialogs.mjs'; import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs'; +import { createCollections, collectionHash, openEditCollectionDialog, missingNote, DELETED_PAGE_TEXT, OPEN_IMAGES_LINK, + LIST_FAILED_TEXT } from './collections.mjs'; import { createActivityStore } from './activity-store.mjs'; import { mountActivityPage, mountActivityTopbar } from './activity.mjs'; import { mountActivityJobView } from './activity-job-view.mjs'; @@ -44,6 +46,7 @@ function mountWorkspace() { const main = document.getElementById('workspace-main'); const primaryNav = document.getElementById('primary-nav'); const savedNav = document.getElementById('saved-nav'); + const collectionsNav = document.getElementById('collections-nav'); const systemNav = document.getElementById('system-nav'); scope.listen(document.querySelector('.skip'), 'click', event => { event.preventDefault(); @@ -83,8 +86,9 @@ function mountWorkspace() { // D129: a node-folder library is offered a move (or "Keep it here") above every page. mountLibraryBanner(document.getElementById('library-banner'), { api, activity, scope }); const sidebar = document.querySelector('aside.sidebar'); - // The route key a nav link carries in `data-route`: a saved view's is `saved/`. - const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` : route.id; + // The route key a nav link carries in `data-route`: a saved view's is `saved/`, a collection's `collection/`. + const routeKey = route => route.id === 'saved' ? `saved/${route.savedId}` + : route.id === 'collection' ? `collection/${route.collectionId}` : route.id; let currentKey = null; const markCurrent = () => { for (const link of sidebar.querySelectorAll('nav [data-route]')) { @@ -110,6 +114,24 @@ function mountWorkspace() { markCurrent(); })); savedViews.load(); + // COLLECTIONS (#331 /* D136 */): the concept's collections-nav rows (folder icon, name, the listed-member count once + // read), without the "PROPOSED" badge. The label shows only while a collection is listed. A failed read keeps the + // last rows; a collection page says the list could not be loaded. + const collectionsLabel = collectionsNav.previousElementSibling; + const collections = createCollections(api, scope.signal); + scope.cleanup(collections.subscribe(({ collections: listed }) => { + collectionsLabel.hidden = listed.length === 0; + collectionsNav.replaceChildren(...listed.map(item => { + const link = element('a'); + link.append(createWorkspaceIcon('folder'), document.createTextNode(item.name)); + if (Number.isSafeInteger(item.count)) link.append(element('span', item.count.toLocaleString(), 'nav-count')); + link.href = collectionHash(item.id); + link.dataset.route = `collection/${item.id}`; + return link; + })); + markCurrent(); + })); + collections.load(); /** The toolbar's Save view on a page: the concept's name dialog, then the store saves the page's full state. */ const saveView = (page, view) => (trigger, state) => openSaveViewDialog({ scope: view, trigger, q: state.q, onSave: name => savedViews.create(page, name, state) }); @@ -167,6 +189,7 @@ function mountWorkspace() { forgetLibraryVisit(); refreshCounts(NAV_COUNT_ROUTES); savedViews.load(); + collections.load(); if (currentKey !== 'activity') navigateTo(window.location.hash); })); // S9 I1: a restore this page started that could not run yet (other requests kept reading) hands its review back: @@ -213,6 +236,10 @@ function mountWorkspace() { openSavedView(route, view); return; } + if (route.id === 'collection') { + openCollectionPage(route, view); + return; + } renderPage(route, view); } @@ -223,14 +250,16 @@ function mountWorkspace() { if (view.signal.aborted) return; // A view saved since the list loaded (another tab) is read once more before the link counts as gone. if (!savedViews.find(route.savedId) && savedViews.status === 'ready') { - await savedViews.load(); + // settled(), not load()'s own promise: a newer read (a restore, another change) can supersede this one. + savedViews.load(); + await savedViews.settled(); if (view.signal.aborted) return; } const saved = savedViews.find(route.savedId); if (saved) { const page = routes.find(item => item.id === saved.page); renderPage({...page, label:saved.name, title:saved.name}, view, saved); - mountEditAction(saved, view); + mountSavedViewEdit(saved, view); return; } if (savedViews.status === 'ready') { @@ -238,7 +267,8 @@ function mountWorkspace() { window.location.replace('#/images'); return; } - const {description} = renderHeading(main, {title:route.label}); + const {description, h1} = renderHeading(main, {title:route.label}); + focusTitle(h1); description.textContent = 'Saved views could not be loaded. Your saved data has not changed.'; description.setAttribute('role', 'alert'); const retry = createButton('Retry'); @@ -249,13 +279,19 @@ function mountWorkspace() { main.append(retry); } - // /* D51 */ The open view's Edit (maintainer ruling): first in the heading's actions. A rename re-reads the list and - // retitles the open page; a delete re-reads it and replaces the dead route with the view's plain page. - function mountEditAction(saved, view) { + // /* D51 */ An open sidebar item's Edit (maintainer ruling): first in the heading's actions; `open(trigger)` opens + // its dialog. Saved views and collections (D137) share it. + function mountEditAction(view, open) { // renderPage has just written the heading. const edit = createButton('Edit'); pageHeading(main).actions.prepend(edit); - view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved, + view.listen(edit, 'click', () => open(edit)); + } + + // A rename re-reads the list and retitles the open page; a delete re-reads it and replaces the dead route with the + // view's plain page. + function mountSavedViewEdit(saved, view) { + mountEditAction(view, trigger => openEditViewDialog({ scope: view, trigger, view: saved, onRename: async name => { const renamed = await savedViews.rename(saved.id, name); if (view.signal.aborted) return; @@ -268,25 +304,106 @@ function mountWorkspace() { } })); } - function retitle(name) { + /** `name` becomes the h1; the breadcrumb and title too unless the page keeps its own (a collection's "Collection"). */ + function retitle(name, title = name) { // Only the mounted view retitles (its rename returns once aborted), so its heading is present. pageHeading(main).h1.textContent = name; - document.getElementById('workspace-breadcrumb').textContent = name; - document.title = `${name} · Prompt Manager`; + document.getElementById('workspace-breadcrumb').textContent = title; + document.title = `${title} · Prompt Manager`; } - function renderPage(route, view, saved = null) { + /** + * #331 D137: `#/collection/` is Images filtered to the collection, its name the h1, "Collection" the breadcrumb + * and title (concept a1186–1189), with Edit and the note about members the sources do not list now. An unknown id + * is read once more (another tab may have made it), then shows the deleted-collection page. + */ + async function openCollectionPage(route, view) { + main.replaceChildren(); + await collections.settled(); + if (view.signal.aborted) return; + if (!collections.find(route.collectionId) && collections.status === 'ready') { + // settled(), not load()'s own promise: the 300 ms count read or a restore can supersede this one. + collections.load(); + await collections.settled(); + if (view.signal.aborted) return; + } + let collection = collections.find(route.collectionId); + const showAgain = () => { if (!view.signal.aborted) navigateTo(window.location.hash); }; + if (collection) { + renderPage({...COLLECTION_ROUTE, label:collection.name, title:routeTitle(COLLECTION_ROUTE)}, view, null, {collection:collection.id, + // The listing found the collection gone meanwhile: the list is read again and, once it no longer lists + // the collection, the page is shown again (the deleted-collection page). A failed read, or a list that + // still holds it, leaves the listing's own error and Retry (images.mjs). + onCollectionMissing:async () => { + collections.load(); + await collections.settled(); + if (collections.status === 'ready' && !collections.find(route.collectionId)) showAgain(); + }}); + // MC3: the note follows every read of the list for the page's life; none while nothing is missing. + const note = element('p', '', 'small'); + const showNote = () => { + const text = missingNote(collections.find(route.collectionId)?.missing); + note.textContent = text; + if (!text) note.remove(); + else if (!note.isConnected) pageHeading(main)?.description.after(note); + }; + showNote(); + view.cleanup(collections.subscribe(showNote)); + // The dialog's Delete question uses the latest stored count. + mountEditAction(view, trigger => openEditCollectionDialog({ scope: view, trigger, + collection: collection = {...collection, ...collections.find(collection.id), name:collection.name}, + onRename: async name => { + const renamed = await collections.rename(collection.id, name); + if (view.signal.aborted) return; + collection = {...collection, ...renamed}; + retitle(renamed.name, routeTitle(COLLECTION_ROUTE)); + }, + onDelete: async () => { + await collections.delete(collection.id); + window.location.replace('#/images'); + }, + onGone: () => collections.settled().then(showAgain) })); + return; + } + const {description, h1} = renderHeading(main, {title:route.label}); + focusTitle(h1); + document.title = `${routeTitle(route)} · Prompt Manager`; + document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); + if (collections.status === 'ready') { + // Deleted, or never existed: say so, with the way back to Images. + description.textContent = DELETED_PAGE_TEXT; + const link = element('a', OPEN_IMAGES_LINK, 'workspace-link'); + link.href = '#/images'; + main.append(link); + return; + } + description.textContent = LIST_FAILED_TEXT; + description.setAttribute('role', 'alert'); + const retry = createButton('Retry'); + view.listen(retry, 'click', () => { + collections.load(); + openCollectionPage(route, view); + }); + main.append(retry); + } + + /** The concept focuses its page title on a hashchange navigation (app.js 1168), never on the first render. */ + function focusTitle(h1) { + if (!firstRender) h1.focus(); + firstRender = false; + } + + function renderPage(route, view, saved = null, {collection = null, onCollectionMissing = null} = {}) { document.title = `${routeTitle(route)} · Prompt Manager`; // The concept's heading(); each view fills its description and actions. // The concept focuses its page title on a hashchange navigation (app.js 1168); the skip link focuses `main`. const { description, h1 } = renderHeading(main, { title: route.label }); - if (!firstRender) h1.focus(); - firstRender = false; + focusTitle(h1); document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); - if (route.id === 'images' || route.id === 'favorites') { + if (route.id === 'images' || route.id === 'favorites' || route.id === 'collection') { mountNewPromptAction(main, api, view); images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', - onSaveView:saveView('images', view), activity}); + collection, onCollectionMissing, onSaveView:saveView('images', view), activity}); return; } if (route.id === 'library') { diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index a179f523..7f027860 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -41,6 +41,8 @@ const FIELDS = Object.freeze({ folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags, // A view saved on Favorites (#249 D51) reopens Images filtered to favorites. favorites: { fallback: false, accepts: value => typeof value === 'boolean' }, + // #331 D140: a view saved on a collection page reopens that collection ('' or 1 to 9 digits, no leading zero). + 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, @@ -54,10 +56,13 @@ export function normalizeViewState(page, state) { const fields = FIELDS[page]; if (!fields) throw new TypeError(`Unknown saved-view page: ${page}`); const source = state && typeof state === 'object' && !Array.isArray(state) ? state : {}; - return Object.fromEntries(Object.entries(fields).map(([key, field]) => { + const normalized = Object.fromEntries(Object.entries(fields).map(([key, field]) => { const value = field.accepts(source[key]) ? source[key] : field.fallback; return [key, Array.isArray(value) ? [...value] : value]; })); + // Favorites and a collection are never both a view's filter: the collection wins, as the listing does (D140). + if (normalized.favorites === true && normalized.collection) normalized.favorites = false; + return normalized; } /** True when the state's compact JSON fits the API's 4 KB cap (the server's encoding: no spaces, UTF-8). */ @@ -96,11 +101,12 @@ export const savedViewHash = id => `#/saved/${id}`; export function imagesFilters(state) { const view = normalizeViewState('images', state); return { q: view.q, category: view.category, sort: view.sort, root: view.source, subfolder: view.folder, media: view.media, - tags: view.tags, favorites: view.favorites }; + tags: view.tags, favorites: view.favorites, collection: view.collection }; } export function imagesViewState(filters) { return normalizeViewState('images', { q: filters.q, category: filters.category, sort: filters.sort, source: filters.root, - folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true }); + folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true, + collection: filters.collection == null ? '' : String(filters.collection) }); } /** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */ @@ -215,14 +221,20 @@ export function openSaveViewDialog({ scope, trigger, q, onSave }) { } /** - * D51: the open view's Edit dialog. "View name" (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel - * and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so nothing is removed by one press and - * no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the store's promise; the toast reports either - * outcome, and the shell navigates after a delete. + * D51: an open sidebar item's Edit dialog, shared by saved views and collections (#331 D137) with their own nouns. The + * name field (Save keeps the dialog open on an empty name; an unchanged one closes it), then Delete… (danger), Cancel + * and Save. Delete… swaps the same dialog to its confirmation, Cancel / Delete (its Cancel closes the dialog), so + * nothing is removed by one press and no browser `confirm()` appears. `onRename(name)` and `onDelete()` return the + * store's promise; the toast reports either outcome, and the shell navigates after a delete. + * + * `nouns`: `title`, `label`, `deleteTitle`, `deleteText(item)`, `renamed`, `renameFailed`, `deleted`, `deleteFailed`. + * By default the dialog closes before the rename is sent (saved views). `renameError(error)` instead keeps it open + * while the rename runs and maps a failure: `{field: text}` writes the message under the field and keeps the dialog, + * `{toast: text, close}` toasts it and closes the dialog only when `close` is true; null is `renameFailed`, kept open. */ -export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) { +export function openEditDialog({ scope, trigger, item, nouns, onRename, onDelete, renameError = null }) { const input = document.createElement('input'); - input.value = view.name; + input.value = item.name; input.maxLength = MAX_NAME_LENGTH; const remove = createButton('Delete…', 'danger'); const cancel = createButton('Cancel'); @@ -230,28 +242,69 @@ export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) const actions = document.createElement('div'); actions.className = 'dialog-actions'; actions.append(remove, cancel, save); + const message = document.createElement('p'); + message.className = 'small'; + message.setAttribute('role', 'alert'); + message.hidden = true; + const say = text => { message.textContent = text; message.hidden = !text; }; cancel.addEventListener('click', () => closeWorkspaceDialog()); save.addEventListener('click', async () => { const name = input.value.trim(); if (!name) { input.focus(); return; } - closeWorkspaceDialog(); - if (name === view.name) return; + if (!renameError || name === item.name) closeWorkspaceDialog(); + if (name === item.name) return; + if (!renameError) { + try { + await onRename(name); + showWorkspaceToast(nouns.renamed); + } catch { + if (!scope.signal.aborted) showWorkspaceToast(nouns.renameFailed); + } + return; + } + // Kept open: one rename at a time, its outcome shown in place. + say(''); + for (const button of [remove, cancel, save]) button.disabled = true; + // Closed meanwhile (Escape, Close): the outcome is only toasted, and a dialog opened since is left alone. + const stillOpen = () => input.isConnected; try { await onRename(name); - showWorkspaceToast(RENAMED_TOAST); - } catch { - if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST); + if (scope.signal.aborted) return; + if (stillOpen()) closeWorkspaceDialog(); + showWorkspaceToast(nouns.renamed); + } catch (error) { + if (scope.signal.aborted) return; + for (const button of [remove, cancel, save]) button.disabled = false; + const outcome = renameError(error) ?? { toast: nouns.renameFailed }; + if (!stillOpen()) { showWorkspaceToast(outcome.field ?? outcome.toast); return; } + if (outcome.field) { say(outcome.field); input.focus(); return; } + if (outcome.close) closeWorkspaceDialog(); + showWorkspaceToast(outcome.toast); } }); - remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete })); - openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field('View name', input, 'action-input'), actions] }); + remove.addEventListener('click', () => confirmDelete({ scope, trigger, item, nouns, onDelete })); + // The message line exists only where a failure keeps the dialog open (the saved views' dialog is unchanged). + const nodes = renameError ? [field(nouns.label, input, 'action-input'), message, actions] : [field(nouns.label, input, 'action-input'), actions]; + openContentDialog({ scope, trigger, title: nouns.title, nodes }); input.focus(); input.select(); } -function confirmDelete({ scope, trigger, view, onDelete }) { +/** The saved views' nouns for the shared Edit dialog (D51). */ +export const SAVED_VIEW_NOUNS = Object.freeze({ + title: 'Edit saved view', label: 'View name', deleteTitle: 'Delete saved view', + deleteText: view => `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`, + renamed: RENAMED_TOAST, renameFailed: RENAME_FAILED_TOAST, deleted: DELETED_TOAST, deleteFailed: DELETE_FAILED_TOAST, +}); + +/** D51: the open view's Edit dialog. */ +export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) { + openEditDialog({ scope, trigger, item: view, nouns: SAVED_VIEW_NOUNS, onRename, onDelete }); +} + +function confirmDelete({ scope, trigger, item, nouns, onDelete }) { const text = document.createElement('p'); - text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`; + text.textContent = nouns.deleteText(item); const actions = document.createElement('div'); actions.className = 'dialog-actions'; const cancel = createButton('Cancel'); @@ -262,11 +315,11 @@ function confirmDelete({ scope, trigger, view, onDelete }) { closeWorkspaceDialog(); try { await onDelete(); - showWorkspaceToast(DELETED_TOAST); + showWorkspaceToast(nouns.deleted); } catch { - if (!scope.signal.aborted) showWorkspaceToast(DELETE_FAILED_TOAST); + if (!scope.signal.aborted) showWorkspaceToast(nouns.deleteFailed); } }); - openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] }); + openContentDialog({ scope, trigger, title: nouns.deleteTitle, nodes: [text, actions] }); cancel.focus(); } diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index e53684df..9af7739c 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -25,7 +25,8 @@ export const routes = [ description: 'Configuration, maintenance and recovery—with browsing kept in the workspace.', action: 'Open System' }, ]; -const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system']); +const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'inspect', 'overview', 'saved', 'favorites', 'activity', 'review', 'system', + 'collection']); /** * System's sections in the concept's order (app.js `systemSections` 635–644); the concept's `general` is production's @@ -45,6 +46,13 @@ export function isSystemSection(id) { /** A saved view (#249 D16): the shell resolves its id to the view's page, name and state. */ const SAVED_ROUTE = Object.freeze({ id: 'saved', label: 'Saved view' }); +/** + * A collection (#331 D137): `#/collection/` is Images filtered to its members. Not a `routes` entry (no Overview + * card, no WORKSPACE link); the shell names the page after the collection, the breadcrumb and title stay "Collection" + * (concept render a1186–1189). + */ +export const COLLECTION_ROUTE = Object.freeze({ id: 'collection', label: 'Collection' }); + /** Breadcrumb and document title: the concept names a few pages differently from their nav label. */ export function routeTitle(route) { return route.title || route.label; @@ -58,6 +66,9 @@ export function isHandoff(route) { export function resolveRoute(hash) { const saved = /^#\/saved\/([1-9]\d*)$/.exec(hash); if (saved && Number.isSafeInteger(Number(saved[1]))) return {...SAVED_ROUTE, savedId:Number(saved[1]), hash}; + // A collection id is the routes' 1 to 9 digits without a leading zero. + const collection = /^#\/collection\/([1-9]\d{0,8})$/.exec(hash); + if (collection) return {...COLLECTION_ROUTE, collectionId:Number(collection[1]), hash}; // One job of the Activity page (its id is the registry's uuid4 hex). const job = /^#\/activity\/([0-9a-f]{32})$/.exec(hash); if (job) return {...routes.find(route => route.id === 'activity'), jobId:job[1], hash}; @@ -151,7 +162,7 @@ export function mountRouter(win, onRoute) { const hash = route.hash || `#/${route.id}`; if (win.location.hash !== hash) win.history.replaceState(win.history.state, '', hash); if (current?.id === route.id && current?.positiveId === route.positiveId && current?.savedId === route.savedId && - current?.jobId === route.jobId && current?.section === route.section) return; + current?.collectionId === route.collectionId && current?.jobId === route.jobId && current?.section === route.section) return; current = route; onRoute(route); }; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 8cf2720b..266e950b 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -12,6 +12,9 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; } sidebar's min-content (the concept's `1fr`); the column is capped at the viewport as production's shell was. */ @media (max-width: 950px) { .app-shell { grid-template-columns: minmax(0, 1fr); /* D31 */ } + /* D136 (#331): the concept hides COLLECTIONS here, which would leave a phone no way to open a collection; its rows + wrap like every other sidebar nav (the concept's `.sidebar nav` flex row). Its label stays hidden, as all are. */ + #collections-nav { display: flex; /* D136 */ } } @media (forced-colors: active) { nav a[aria-current="page"] { border: 2px solid Highlight; color: LinkText; } diff --git a/web/workspace.html b/web/workspace.html index c0532f66..1f1860b1 100644 --- a/web/workspace.html +++ b/web/workspace.html @@ -25,6 +25,9 @@ + + +