diff --git a/TESTING.md b/TESTING.md index 295b0f1..83f9eb0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -445,8 +445,29 @@ Recorded shell deviations: compares the rows; `tests/browser/workspace-saved-views.spec.mjs` covers saving, reopening, the defaults that replace rejected values, and failed reads and saves. - **D50**: no "Needs review" row in SAVED VIEWS until the tag-review slice (maintainer - ruling, 2026-10-21). Favorites arrives with the saved-views plan's Task 3; until then - the comparison maps it out with Needs review. + ruling, 2026-10-21). The comparison maps only that row out. +- **D51** (maintainer ruling, 2026-10-21, beyond the concept): SAVED VIEWS starts with + the concept's Favorites row (star icon, name, count). Its count is the Favorites + listing's own `limit=1` total (`/images/output?mode=workspace&favorites=1`), follows + the nav-count rules (hidden until loaded, absent when its read fails, never a + placeholder) and is re-read after every confirmed star or unstar, on any page. + `#/favorites` is Images filtered to favorites, named "Favorites" (concept + `renderImages`); `favorites=1` keeps only the selected roots' favorites, read per root + from `image_favorites` and matched against the scan with the toggle's case folding, + and a failed read is an error, never an unfiltered page. Unlike the concept, an + unstarred card stays on Favorites (showing ☆) until the next load, so the star can be + undone in place. Favorites neither restores nor replaces the Images return state. + Save view there saves `favorites: true`, so the view reopens the favorites. An open + saved view's heading leads with **Edit**: "Edit saved view" with "View name" and + Delete… (danger), Cancel and Save. Save re-reads the list, retitles the page and + toasts "Saved view renamed."; an empty name keeps the dialog open. Delete… swaps the + same dialog to "Delete saved view" (Cancel / Delete; never `window.confirm`); Delete + removes the view, replaces the dead route with the view's plain page and toasts + "Saved view deleted.". Failures toast "The view could not be renamed. Try again." or + "The view could not be deleted. Try again."; a view another tab already deleted counts + as deleted. `tests/browser/workspace-saved-views.spec.mjs` covers the row, count, + route, star updates, Save view on Favorites, Edit, and the failure paths; + `tests/test_image_favorites.py` covers `favorites=1`. - **D29**: no prototype copy. There is no "Interactive concept" badge and no "What changes & why" button. The footer reads "ComfyAssets · Local tools. Your library.", and the title has no " concept" suffix. Save view's dialog action reads diff --git a/database/image_favorites.py b/database/image_favorites.py index 4334926..b603498 100644 --- a/database/image_favorites.py +++ b/database/image_favorites.py @@ -137,3 +137,16 @@ class ImageFavoriteOperations: ) found.update(path for (path,) in rows) return found + + def get_root_favorites(self, root_key: str) -> set[str]: + """Every relative key favorited under *root_key* (a primary-key range). + + The Favorites listing (``favorites=1``) intersects these with the + root's scan, so its size follows the root's favorites, not its files. + """ + conn = self.model.get_connection() + rows = conn.execute( + "SELECT relative_path FROM image_favorites WHERE root_key=?", + (root_key,), + ) + return {path for (path,) in rows} diff --git a/database/saved_views.py b/database/saved_views.py index 3c3fc96..4df3d3e 100644 --- a/database/saved_views.py +++ b/database/saved_views.py @@ -35,6 +35,8 @@ _STATE_FIELDS = { "folder": ("text", 1024), "media": ("choice", _MEDIA), "tags": ("tags", None), + # The Favorites page's filter (#249 D51): a view saved there reopens it. + "favorites": ("bool", None), }, "library": { "q": ("text", 500), @@ -92,6 +94,10 @@ def _validate_value(key: str, kind: str, limit, value: object) -> None: ): raise ValueError(f"State '{key}' holds an invalid tag") return + if kind == "bool": + if not isinstance(value, bool): + raise ValueError(f"State '{key}' must be true or false") + return if not isinstance(value, str): raise ValueError(f"State '{key}' must be a string") if kind == "choice" and value not in limit: diff --git a/py/api/images.py b/py/api/images.py index 1ed0642..fb3046c 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -696,7 +696,9 @@ def _workspace_filters(query: Mapping) -> dict: ``q`` (at most 500 characters) matches the relative path, case-folded, and the linked positive's text, title and tag names. ``category`` is exact, ``tags`` is a comma list that must all be present, ``sort=rating`` ranks by - the linked positive. Raises ValueError for any unknown or oversized value. + the linked positive, and ``favorites=1`` keeps only favorites (the + Favorites page, #249 D51). Raises ValueError for any unknown or oversized + value. """ text = query.get("q", "").strip() folded = text.casefold() @@ -707,8 +709,10 @@ def _workspace_filters(query: Mapping) -> dict: tags = sorted( {tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()} ) + favorites = query.get("favorites", "0") if ( - len(text) > 500 + favorites not in ("0", "1") + or len(text) > 500 or len(folded) > 500 or media not in ("all", "image", "video") or order not in WORKSPACE_ORDERS @@ -727,6 +731,7 @@ def _workspace_filters(query: Mapping) -> dict: "folder": folder, "category": category, "tags": tags, + "favorites": favorites == "1", } @@ -736,7 +741,12 @@ _COUNT_OMITTED = object() def _filtered_page( - entries: list, start: int, size: int, filters: dict, links: dict | None + entries: list, + start: int, + size: int, + filters: dict, + links: dict | None, + favorites: frozenset | None = None, ) -> tuple[int, list, int | None]: """Count the *entries* the filters admit and take *size* from *start* on. @@ -745,6 +755,7 @@ def _filtered_page( category or tag filter admits only those, and ``q`` admits a filename match or a linked-positive hit. Returns the total, the page and the number of distinct linked positives among all admitted entries (None without links). + *favorites*, when given, admits only those listed paths (``favorites=1``). """ query, media, folder = filters["query"], filters["media"], filters["folder"] members_only = filters["category"] is not None or bool(filters["tags"]) @@ -758,6 +769,8 @@ def _filtered_page( continue if media != "all" and (media == "video") != entry.video: continue + if favorites is not None and entry.path not in favorites: + continue match = None if links is None else links.get(entry.normalized) if members_only and match is None: continue @@ -1146,6 +1159,7 @@ class ImageRoutesMixin: [str(root) for root in output_dirs], filters["category"], filters["tags"], + filters["favorites"], ] ).encode() ).hexdigest() @@ -1333,8 +1347,11 @@ class ImageRoutesMixin: start = len(entries) - bisect_left(ascending, after, key=key) else: start = bisect_right(entries, after, key=key) + favorites = ( + self._workspace_favorite_paths(listings) if filters["favorites"] else None + ) total, page, positive_count = _filtered_page( - entries, start, limit + 1, filters, links + entries, start, limit + 1, filters, links, favorites ) if not needed and after is not None: positive_count = _COUNT_OMITTED @@ -2347,6 +2364,25 @@ class ImageRoutesMixin: self._favorite_listing_cache[cache_key] = (files, listing) return listing + def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset: + """The listed paths of every favorite in the selected roots (blocking). + + One primary-key read per root (get_root_favorites), matched against the + root's cached ``{relative key: listing spelling}``, so the work follows + the favorites rather than the files, and a key folds case exactly as + the toggle stored it (Windows). A favorite whose file is gone matches + nothing. A failed read raises: an unfiltered page must never pass for + the favorites. + """ + paths = set() + for _index, root, files in listings: + listing = self._favorite_listing(root, files) + for key in self.db.get_root_favorites(_favorite_root_key(root)): + listed = listing.get(key) + if listed is not None: + paths.add(root / listed) + return frozenset(paths) + def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]: """``(root index, listing path)`` of the favorites among *page* entries.""" by_root: dict[tuple[int, Path], dict[str, str]] = {} diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 1ff22b8..966bd64 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -97,6 +97,8 @@ export async function openProduction(page,route,theme,motion='no-preference') { await selectTheme(page,theme); await expect(page.locator('#workspace-main #page-title')).toBeVisible(); await expect(page.locator('#primary-nav .nav-count')).toHaveCount(3); + // SAVED VIEWS' Favorites count (D51), the concept's starred images in the concept fixture. + await expect(page.locator('#saved-nav a[data-route="favorites"] .nav-count')).toHaveCount(1); } /** Computed outline of the focused element, after keyboard focus so `:focus-visible` applies. */ diff --git a/tests/browser/shell-concept.spec.mjs b/tests/browser/shell-concept.spec.mjs index ad35f61..995f4a6 100644 --- a/tests/browser/shell-concept.spec.mjs +++ b/tests/browser/shell-concept.spec.mjs @@ -57,11 +57,11 @@ function remainingFooter(footer,spans) { const OVERVIEW_GLYPH='M3 11 12 4l9 7M5 10v10h14V10M10 20v-6h4v6'; /** - * The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: the saved views only. D50: no "Needs - * review" row until the tag-review slice (maintainer ruling 2026-10-21). Favorites is the saved-views plan's Task 3. + * The concept's SAVED VIEWS rows (app.js a331–335) as production lists them: Favorites with its count (D51), then the + * saved views. D50: no "Needs review" row until the tag-review slice (maintainer ruling 2026-10-21). */ function savedNav(nav) { - const rows=nav.rows.filter(row=>row.text!=='Favorites'&&row.text!=='Needs review'); + const rows=nav.rows.filter(row=>row.text!=='Needs review'); return {...nav,rows:rows.map((row,index)=>index===rows.length-1?{...row,gapAfter:null}:row)}; } @@ -231,7 +231,7 @@ for(const [width,height] of SIZES)test(`a saved view row matches the concept's $ const actual=await save('Save','#workspace-main'); expect(diffInventory(mapShellConcept(reference).chrome.navs,actual.chrome.navs)).toEqual([]); expect(actual.chrome.navs.find(nav=>nav.id==='saved-nav').rows.map(row=>[row.text,row.count])) - .toEqual([['Golden hour',null]]); + .toEqual([['Favorites',reference.chrome.navs.find(nav=>nav.id==='saved-nav').rows[0].count],['Golden hour',null]]); }); test('the inventory reports a missing shell element by its path instead of failing',async({page})=>{ diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index c3d57a5..7a1f1e9 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -13,10 +13,17 @@ const filtersButton = page => toolbar(page).getByRole('button', { name: /^Filter const imageCards = page => page.locator('article[data-image-key]'); const promptCards = page => page.locator('article[data-prompt-id]'); const savedNav = page => page.getByRole('navigation', { name: 'Saved views', exact: true }); +// The saved views' own rows, after the Favorites row (D51). +const viewRows = page => savedNav(page).locator('a[data-route^="saved/"]'); +const favoritesRow = page => savedNav(page).locator('a[data-route="favorites"]'); +const favoritesCount = page => favoritesRow(page).locator('.nav-count'); +const editDialog = page => page.getByRole('dialog', { name: 'Edit saved view', exact: true }); +const deleteDialog = page => page.getByRole('dialog', { name: 'Delete saved view', exact: true }); +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: [] }; +const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false }; async function refine(page, values) { await filtersButton(page).click(); @@ -46,6 +53,8 @@ test.beforeEach(async ({ page, request, browserName }) => { await expect(await request.post('/__test__/reset')).toBeOK(); page.savedErrors = []; page.on('pageerror', error => page.savedErrors.push(error.message)); + // D51: deleting asks inside the dialog; no browser dialog (window.confirm) may ever open. + page.on('dialog', dialog => { page.savedErrors.push(`browser ${dialog.type()}`); dialog.dismiss().catch(() => {}); }); // No listing read may fail on a saved view's values (an unknown Source would be a 400). page.on('response', response => { if (response.status() >= 400 && new URL(response.url()).pathname === '/prompt_manager/images/output') { @@ -65,7 +74,7 @@ test('Save view saves the Images toolbar and Refine filters, and its sidebar row 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']); - await expect(savedNav(page).locator('a')).toHaveCount(0); + await expect(viewRows(page)).toHaveCount(0); await search(page).fill('dogs'); await expect(imageCards(page)).toHaveCount(1); @@ -134,7 +143,7 @@ test('an empty name is Saved view, and a Library view reopens the shelf filters' await expect(name).toHaveValue('My workspace view'); await name.fill(' '); await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); - await expect(savedNav(page).getByRole('link')).toHaveText(['Saved view']); + await expect(viewRows(page)).toHaveText(['Saved view']); expect((await listed(request))[0]).toMatchObject({ page: 'images', name: 'Saved view', state: IMAGE_DEFAULTS }); await page.goto(`${workspace}#/library`); @@ -146,7 +155,7 @@ test('an empty name is Saved view, and a Library view reopens the shelf filters' await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click(); await expect(name).toHaveValue('Search: dog'); await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); - await expect(savedNav(page).getByRole('link')).toHaveText(['Saved view', 'Search: dog']); + await expect(viewRows(page)).toHaveText(['Saved view', 'Search: dog']); const library = (await listed(request))[1]; expect(library).toMatchObject({ page: 'library', name: 'Search: dog', state: { q: 'dog', category: '', sort: 'newest', tags: ['dogs'] } }); @@ -199,13 +208,13 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as : route.fallback()); await page.goto(`${workspace}#/saved/${view.id}`); await expect(main(page).getByRole('alert')).toHaveText('Saved views could not be loaded. Your saved data has not changed.'); - await expect(savedNav(page).locator('a')).toHaveCount(0); + await expect(viewRows(page)).toHaveCount(0); failing = false; await main(page).getByRole('button', { name: 'Retry', exact: true }).click(); await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Cats'); await expect(search(page)).toHaveValue('cat'); await expect(promptCards(page)).toHaveCount(1); - await expect(savedNav(page).getByRole('link')).toHaveText(['Cats']); + await expect(viewRows(page)).toHaveText(['Cats']); }); test('a failed save says so and adds nothing', async ({ page, request }) => { @@ -217,6 +226,200 @@ test('a failed save says so and adds nothing', async ({ page, request }) => { await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click(); await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); await expect(toast(page)).toHaveText('The view could not be saved. Try again.'); - await expect(savedNav(page).locator('a')).toHaveCount(0); + await expect(viewRows(page)).toHaveCount(0); expect(await listed(request)).toEqual([]); }); + +async function star(page, key) { + await page.locator(`article[data-image-key="${key}"] .star-button`).click(); +} + +test('Favorites is the first SAVED VIEWS row, with a real count that follows every star', async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + let release; + const held = new Promise(resolve => { release = resolve; }); + // Hidden until its read settles: never a placeholder "0". + await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1' + && url.searchParams.get('limit') === '1', async route => { await held; await route.fallback(); }, { times: 1 }); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(2); + await expect(favoritesRow(page)).toHaveAttribute('href', '#/favorites'); + await expect(favoritesCount(page)).toHaveCount(0); + release(); + // The concept's row (app.js a332): the star icon, "Favorites", then the count. + await expect(favoritesCount(page)).toHaveText('0'); + expect(await favoritesRow(page).evaluate(link => [...link.childNodes].map(node => node.nodeType === Node.TEXT_NODE + ? `text:${node.textContent}` : `${node.localName}.${node.getAttribute('class')}`))) + .toEqual(['svg.icon', 'text:Favorites', 'span.nav-count']); + expect(await savedNav(page).locator('a').evaluateAll(links => links.map(link => link.dataset.route))).toEqual(['favorites']); + + await star(page, '1:shared/same.png'); + await expect(toast(page)).toHaveText('Added to favorites.'); + await expect(favoritesCount(page)).toHaveText('1'); + + // #/favorites: Images filtered to favorites, named Favorites (concept renderImages a417). + const listings = []; + page.on('request', item => { + const url = new URL(item.url()); + if (url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('limit') !== '1') listings.push(url.searchParams); + }); + await favoritesRow(page).click(); + await expect(page).toHaveURL(/#\/favorites$/); + await expect(heading(page)).toHaveText('Favorites'); + await expect(heading(page)).toBeFocused(); + await expect(page.locator('#workspace-breadcrumb')).toHaveText('Favorites'); + await expect(page).toHaveTitle('Favorites · Prompt Manager'); + await expect(favoritesRow(page)).toHaveAttribute('aria-current', 'page'); + await expect(page.locator('aside.sidebar nav a[aria-current]')).toHaveCount(1); + await expect(imageCards(page)).toHaveCount(1); + await expect(imageCards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); + await expect(main(page).locator('#result-count')).toHaveText(/^1 of 1 image/); + expect(listings.at(-1).get('favorites')).toBe('1'); + + // Unstarring on Favorites keeps the card (with ☆) so it can be undone in place; the count follows. + await star(page, '1:shared/same.png'); + await expect(toast(page)).toHaveText('Removed from favorites.'); + await expect(imageCards(page).locator('.star-button')).toHaveAttribute('aria-pressed', 'false'); + await expect(favoritesCount(page)).toHaveText('0'); + await star(page, '1:shared/same.png'); + await expect(favoritesCount(page)).toHaveText('1'); + + // Favorites never replaces the Images return state: Images still lists everything. + await page.getByRole('navigation', { name: 'Workspace', exact: true }).getByRole('link', { name: /^Images/ }).click(); + await expect(heading(page)).toHaveText('Images'); + await expect(imageCards(page)).toHaveCount(2); + await page.goto(`${workspace}#/favorites`); + await expect(heading(page)).toHaveText('Favorites'); + await expect(imageCards(page)).toHaveCount(1); +}); + +test('a failed Favorites count shows no badge', async ({ page }) => { + await page.route(url => url.pathname.endsWith('/prompt_manager/images/output') && url.searchParams.get('favorites') === '1' + && url.searchParams.get('limit') === '1', route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } })); + await page.goto(`${workspace}#/images`); + await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText('2'); + await expect(favoritesRow(page)).toBeVisible(); + await expect(favoritesCount(page)).toHaveCount(0); + // The refused count read is this test's own; nothing else failed. + page.savedErrors = page.savedErrors.filter(error => !/^503 .*favorites=1/.test(error)); +}); + +test('Save view on Favorites reopens the favorites', async ({ page, request }) => { + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(2); + await star(page, '0:shared/same.png'); + await expect(favoritesCount(page)).toHaveText('1'); + await page.goto(`${workspace}#/favorites`); + await expect(imageCards(page)).toHaveCount(1); + await toolbar(page).getByRole('button', { name: 'Save view', exact: true }).click(); + await saveDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Best'); + await saveDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Saved view added to the sidebar.'); + const [view] = await listed(request); + expect(view).toMatchObject({ page: 'images', name: 'Best', state: { ...IMAGE_DEFAULTS, favorites: true } }); + await expect(viewRows(page)).toHaveText(['Best']); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(2); + await viewRows(page).click(); + await expect(heading(page)).toHaveText('Best'); + await expect(imageCards(page)).toHaveCount(1); + await expect(imageCards(page)).toHaveAttribute('data-image-key', '0:shared/same.png'); +}); + +test('Edit renames an open view, and deletes it only after confirming inside the dialog', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const view = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } }); + await page.goto(`${workspace}#/images`); + await expect(imageCards(page)).toHaveCount(2); + // Plain pages have no Edit. + await expect(main(page).locator('.heading-actions').getByRole('button', { name: 'Edit', exact: true })).toHaveCount(0); + await viewRows(page).click(); + await expect(heading(page)).toHaveText('Dogs'); + // D51: Edit leads the heading's actions of an open view. + const edit = main(page).locator('.heading-actions > button').first(); + await expect(edit).toHaveText('Edit'); + await edit.click(); + const dialog = editDialog(page); + const name = dialog.getByRole('textbox', { name: 'View name', exact: true }); + await expect(name).toHaveValue('Dogs'); + await expect(name).toBeFocused(); + await expect(dialog.locator('.dialog-actions button')).toHaveText(['Delete…', 'Cancel', 'Save']); + await expect(dialog.getByRole('button', { name: 'Delete…', exact: true })).toHaveClass(/\bdanger\b/); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(edit).toBeFocused(); + + // An empty name keeps the dialog open on its field. + await edit.click(); + await name.fill(' '); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(name).toBeFocused(); + await name.fill('Good dogs'); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('Saved view renamed.'); + await expect(viewRows(page)).toHaveText(['Good dogs']); + await expect(heading(page)).toHaveText('Good dogs'); + await expect(page.locator('#workspace-breadcrumb')).toHaveText('Good dogs'); + await expect(page).toHaveTitle('Good dogs · Prompt Manager'); + await expect(edit).toBeFocused(); + expect(await listed(request)).toEqual([{ ...view, name: 'Good dogs' }]); + // The rename kept the view's page state. + await expect(search(page)).toHaveValue('dogs'); + await expect(imageCards(page)).toHaveCount(1); + + // Delete… asks first; its Cancel deletes nothing. + await edit.click(); + await dialog.getByRole('button', { name: 'Delete…', exact: true }).click(); + const confirm = deleteDialog(page); + await expect(confirm).toContainText('Delete “Good dogs”? Only the saved view is removed; its images and prompts stay.'); + await expect(confirm.locator('.dialog-actions button')).toHaveText(['Cancel', 'Delete']); + await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); + await confirm.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(confirm).toHaveCount(0); + expect(await listed(request)).toHaveLength(1); + + await edit.click(); + await dialog.getByRole('button', { name: 'Delete…', exact: true }).click(); + await confirm.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect(toast(page)).toHaveText('Saved view deleted.'); + await expect(page).toHaveURL(/#\/images$/); + await expect(heading(page)).toHaveText('Images'); + await expect(viewRows(page)).toHaveCount(0); + expect(await listed(request)).toEqual([]); + // The dead link left no history entry. + await page.goBack(); + await expect(page).not.toHaveURL(new RegExp(`#/saved/${view.id}$`)); +}); + +test('a deleted Library view returns to the Library, and failed edits say so', async ({ page, request }) => { + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + await page.goto(`${workspace}#/saved/${view.id}`); + await expect(heading(page)).toHaveText('Cats'); + const edit = main(page).locator('.heading-actions > button').first(); + await expect(edit).toHaveText('Edit'); + let failing = true; + await page.route(`**${API}/${view.id}`, route => failing + ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"Saved view operation failed"}' }) + : route.fallback()); + await edit.click(); + await editDialog(page).getByRole('textbox', { name: 'View name', exact: true }).fill('Kittens'); + await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click(); + await expect(toast(page)).toHaveText('The view could not be renamed. Try again.'); + await expect(heading(page)).toHaveText('Cats'); + await expect(viewRows(page)).toHaveText(['Cats']); + await edit.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('The view could not be deleted. Try again.'); + await expect(page).toHaveURL(new RegExp(`#/saved/${view.id}$`)); + expect(await listed(request)).toHaveLength(1); + + failing = false; + await edit.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('Saved view deleted.'); + await expect(page).toHaveURL(/#\/library$/); + await expect(heading(page)).toHaveText('Prompt library'); + await expect(viewRows(page)).toHaveCount(0); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index 7a54972..9290025 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -18,6 +18,8 @@ const routes = [ ['tags', 'Tags & categories', null], ['inspect', 'Inspect a file', '/prompt_manager/gallery.html'], ['system', 'System', '/prompt_manager/admin'], + // #249 D51: SAVED VIEWS' Favorites, Images filtered to favorites. + ['favorites', 'Favorites', null], ]; async function countThemeWrites(page) { diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js index 9a8e407..f9f33c0 100644 --- a/tests/js/workspace-images-state.test.js +++ b/tests/js/workspace-images-state.test.js @@ -128,6 +128,17 @@ test('all-source defaults are omitted and descending name sort remains supported await stream.reset({root:'all',media:'all',sort:'name-desc'}); assert.equal(query.has('root'),false);assert.equal(query.has('media'),false); assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40'); + assert.equal(query.has('favorites'),false); +}); + +test('the Favorites page asks the listing for favorites only (#249 D51)', 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({favorites:true,q:'fog'}); + assert.equal(query.get('favorites'),'1');assert.equal(query.get('q'),'fog'); + await stream.reset({favorites:false}); + assert.equal(query.has('favorites'),false); + for(const favorites of ['1',1,'true'])await assert.rejects(stream.reset({favorites}),/Invalid favorites filter/); }); test('restored browsing state resumes its cursor and filters without an initial request', async () => { diff --git a/tests/js/workspace-saved-views.test.js b/tests/js/workspace-saved-views.test.js index 7ea4da7..3d45868 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: [] }; +const IMAGE_DEFAULTS = { q: '', category: '', sort: 'newest', source: 'all', folder: '', media: 'all', tags: [], favorites: false }; 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,10 @@ 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'] }; + const full = { q: 'castle', category: 'Portraits', sort: 'name-desc', source: '2', folder: 'a/b', media: 'video', tags: ['moody', 'fog'], favorites: true }; assert.deepEqual(normalizeViewState('images', full), full); + // 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. assert.deepEqual(normalizeViewState('images', { ...full, sort: 'bogus', source: '-1', media: 'audio', tags: ['a,b'], extra: 1 }), { ...full, sort: 'newest', source: 'all', media: 'all', tags: [] }); @@ -61,11 +63,12 @@ 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'] }; + const filters = { q: 'castle', category: 'Portraits', sort: 'rating', root: '1', subfolder: 'x', media: 'image', tags: ['fog'], favorites: true }; const state = imagesViewState(filters); - assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'] }); + assert.deepEqual(state, { q: 'castle', category: 'Portraits', sort: 'rating', source: '1', folder: 'x', media: 'image', tags: ['fog'], favorites: true }); assert.deepEqual(imagesFilters(state), filters); - assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [] }); + assert.deepEqual(imagesFilters({}), { q: '', category: '', sort: 'newest', root: 'all', subfolder: '', media: 'all', tags: [], favorites: false }); + 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'] }); assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf); @@ -84,6 +87,14 @@ function fakeApi() { calls.push(['POST', url, body]); return Promise.resolve({ success: true, view: { id: 9, ...body } }); }, + patch(url, body) { + calls.push(['PATCH', url, body]); + return api.patchResult ?? Promise.resolve({ success: true, view: { id: 9, page: 'images', name: body.name, state: {} } }); + }, + del(url) { + calls.push(['DELETE', url]); + return api.deleteResult ?? Promise.resolve({ success: true, id: 9 }); + }, }; return { api, pending }; } @@ -136,3 +147,43 @@ test('creating a view posts the full state and re-reads the list', async () => { const broken = createSavedViews({ ...api, post: () => Promise.resolve({ success: true, view: null }) }); await assert.rejects(broken.create('images', 'X', {}), TypeError); }); + +test('renaming and deleting a view re-read the list, whatever the outcome', async () => { + const { createSavedViews, SAVED_VIEWS_URL } = await source; + const { api, pending } = fakeApi(); + const store = createSavedViews(api); + const renamed = store.rename(9, 'Dusk'); + await flush(); + assert.deepEqual(api.calls[0], ['PATCH', `${SAVED_VIEWS_URL}/9`, { name: 'Dusk' }]); + assert.deepEqual(api.calls[1], ['GET', SAVED_VIEWS_URL, 'no-store']); + pending[0].resolve({ success: true, views: [{ id: 9, page: 'images', name: 'Dusk', state: {} }] }); + assert.deepEqual(await renamed, { id: 9, page: 'images', name: 'Dusk', state: IMAGE_DEFAULTS }); + assert.equal(store.find(9).name, 'Dusk'); + + const removed = store.remove(9); + await flush(); + assert.deepEqual(api.calls[2], ['DELETE', `${SAVED_VIEWS_URL}/9`]); + pending[1].resolve({ success: true, views: [] }); + assert.equal(await removed, 9); + assert.equal(store.find(9), null); + + // A view another tab already deleted is the outcome asked for; any other failure rejects, after a re-read. + api.deleteResult = Promise.reject(Object.assign(new Error('Not found'), { status: 404 })); + const gone = store.remove(4); + await flush(); + pending[2].resolve({ success: true, views: [] }); + assert.equal(await gone, 4); + api.deleteResult = Promise.reject(Object.assign(new Error('Locked'), { status: 500 })); + const failed = store.remove(4); + await flush(); + pending[3].resolve({ success: true, views: [] }); + await assert.rejects(failed, /Locked/); + api.patchResult = Promise.resolve({ success: true, view: null }); + const invalid = store.rename(4, 'X'); + await flush(); + pending[4].resolve({ success: true, views: [] }); + await assert.rejects(invalid, TypeError); + assert.equal(pending.length, 5); + assert.throws(() => store.rename(0, 'X'), TypeError); + assert.throws(() => store.remove('9'), TypeError); +}); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index da61be3..6556a55 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -79,18 +79,19 @@ test('positive library deep links preserve the selected identity without duplica test('nav counts reject negative, non-integer and missing totals', async () => { const { summarizeNavCounts } = await source; - const counts = summarizeNavCounts({images: {total: 24}, library: {total: 0}, tags: {total: 7}}); + const counts = summarizeNavCounts({images: {total: 24}, favorites: {total: 3}, library: {total: 0}, tags: {total: 7}}); assert.ok(counts instanceof Map); - assert.deepEqual([...counts], [['images', 24], ['library', 0], ['tags', 7]]); + assert.deepEqual([...counts], [['images', 24], ['favorites', 3], ['library', 0], ['tags', 7]]); assert.equal(counts.has('inspect'), false, 'Inspect a file has no count'); for (const invalid of [undefined, null, {}, {total: -1}, {total: 1.5}, {total: '3'}, {total: NaN}, {total: Infinity}, {total: Number.MAX_SAFE_INTEGER + 1}, {total: null}, [], 'x']) { - const result = summarizeNavCounts({images: invalid, library: {total: 2}, tags: invalid}); + const result = summarizeNavCounts({images: invalid, favorites: invalid, library: {total: 2}, tags: invalid}); assert.equal(result.get('images'), null, JSON.stringify(invalid)); + assert.equal(result.get('favorites'), null); assert.equal(result.get('library'), 2); assert.equal(result.get('tags'), null); } - assert.deepEqual([...summarizeNavCounts()], [['images', null], ['library', null], ['tags', null]]); + assert.deepEqual([...summarizeNavCounts()], [['images', null], ['favorites', null], ['library', null], ['tags', null]]); }); test('nav counts settle independently so a slow images read never delays the others', async () => { @@ -98,6 +99,7 @@ test('nav counts settle independently so a slow images read never delays the oth let releaseImages; const images = new Promise(resolve => { releaseImages = resolve; }); const api = {get: async url => { + if (url.includes('favorites=1')) return {success: true, total: 2}; if (url.startsWith('/prompt_manager/images')) return images; if (url.startsWith('/prompt_manager/library')) return {success: true, total: 3}; throw new Error('Unavailable'); @@ -105,14 +107,14 @@ test('nav counts settle independently so a slow images read never delays the oth const settled = []; const done = loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count])); await new Promise(resolve => setTimeout(resolve, 0)); - assert.deepEqual(settled.sort(), [['library', 3], ['tags', null]]); + assert.deepEqual(settled.sort(), [['favorites', 2], ['library', 3], ['tags', null]]); releaseImages({success: true, total: 40}); const counts = await done; assert.deepEqual(settled.at(-1), ['images', 40]); - assert.deepEqual([...counts], [['images', 40], ['library', 3], ['tags', null]]); + assert.deepEqual([...counts], [['images', 40], ['favorites', 2], ['library', 3], ['tags', null]]); }); -test('nav counts issue three bounded requests and drop only the failed count', async () => { +test('nav counts issue four bounded requests and drop only the failed count', async () => { const { loadNavCounts } = await source; const calls = []; const signal = new AbortController().signal; @@ -123,15 +125,42 @@ test('nav counts issue three bounded requests and drop only the failed count', a return {success: true, total: 12}; }}; const counts = await loadNavCounts(api, signal); - assert.deepEqual([...counts], [['images', 12], ['library', null], ['tags', 5]]); + assert.deepEqual([...counts], [['images', 12], ['favorites', 12], ['library', null], ['tags', 5]]); const urls = calls.map(([url]) => new URL(url, 'http://host')); assert.deepEqual(urls.map(url => url.pathname), - ['/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']); + ['/prompt_manager/images/output', '/prompt_manager/images/output', '/prompt_manager/library', '/prompt_manager/vocabulary/tags']); for (const url of urls) assert.equal(url.searchParams.get('limit'), '1'); assert.equal(urls[0].searchParams.get('mode'), 'workspace'); + assert.equal(urls[0].searchParams.has('favorites'), false); + // D51: the Favorites count is the Favorites page's own listing. + assert.equal(urls[1].searchParams.get('mode'), 'workspace'); + assert.equal(urls[1].searchParams.get('favorites'), '1'); assert.ok(calls.every(([, used]) => used === signal)); }); +test('a favorite change re-reads only the Favorites count', async () => { + const { loadNavCounts, FAVORITE_WRITE_COUNTS } = await source; + assert.deepEqual([...FAVORITE_WRITE_COUNTS], ['favorites']); + assert.ok(Object.isFrozen(FAVORITE_WRITE_COUNTS)); + const calls = []; + const api = {get: async url => { calls.push(url); return {success: true, total: 0}; }}; + const counts = await loadNavCounts(api, new AbortController().signal, () => {}, FAVORITE_WRITE_COUNTS); + assert.deepEqual(calls, ['/prompt_manager/images/output?mode=workspace&favorites=1&limit=1']); + // A real zero is a count, unlike an outstanding read. + assert.deepEqual([...counts], [['favorites', 0]]); +}); + +test('the Favorites route is native and resolves by its hash', async () => { + const { resolveRoute, isHandoff, routeTitle, routes } = await source; + const route = resolveRoute('#/favorites'); + assert.equal(route.id, 'favorites'); + assert.equal(route.label, 'Favorites'); + assert.equal(routeTitle(route), 'Favorites'); + assert.equal(isHandoff(route), false); + // No href: Overview lists no legacy card for it. + assert.equal(routes.find(item => item.id === 'favorites').href, undefined); +}); + test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => { const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source; assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']); diff --git a/tests/test_image_favorites.py b/tests/test_image_favorites.py index b2d4a2c..e2fd85e 100644 --- a/tests/test_image_favorites.py +++ b/tests/test_image_favorites.py @@ -292,6 +292,134 @@ class TestImageFavoriteApi(ImageAPITestCase): self.assertEqual(data, {"success": False, "error": "Favorite update failed"}) +class TestWorkspaceFavoritesFilter(ImageAPITestCase): + """``favorites=1`` on the workspace listing: the Favorites page (#249 D51).""" + + async def tearDownAsync(self): + self.api.db.close_all() + assert_closed(self, self._temp_db.name, self._output_tmp.name) + await super().tearDownAsync() + + async def page(self, status=200, **params): + response = await self.client.get( + "/prompt_manager/images/output", params={"mode": "workspace", **params} + ) + data = await response.json() + self.assertEqual(response.status, status, data) + return data + + async def star(self, path, root_index=0, method="PUT"): + response = await self.client.request( + method, FAVORITE, json={"root_index": root_index, "path": path} + ) + self.assertEqual(response.status, 200) + + def make(self, *names): + for offset, name in enumerate(names): + target = make_png(self.output_dir / name) + os.utime(target, (1_700_000_000 + offset, 1_700_000_000 + offset)) + + async def test_lists_only_favorites_and_counts_them(self): + self.make("a.png", "sub/b.png", "c.png", "sub/d.png") + await self.star("a.png") + await self.star("sub/d.png") + data = await self.page(favorites="1") + self.assertEqual([i["path"] for i in data["images"]], ["sub/d.png", "a.png"]) + self.assertEqual(data["total"], 2) + self.assertTrue(all(i["favorite"] is True for i in data["images"])) + # The nav count's read: limit=1 still reports every favorite's total. + self.assertEqual((await self.page(favorites="1", limit="1"))["total"], 2) + # Other filters intersect with it. + sub = await self.page(favorites="1", subfolder="sub") + self.assertEqual([i["path"] for i in sub["images"]], ["sub/d.png"]) + self.assertEqual((await self.page(favorites="1", q="a.png"))["total"], 1) + self.assertEqual((await self.page(favorites="1", media="video"))["total"], 0) + # favorites=0 (and no parameter) is the whole listing. + self.assertEqual((await self.page(favorites="0"))["total"], 4) + self.assertEqual((await self.page())["total"], 4) + await self.star("a.png", method="DELETE") + self.assertEqual((await self.page(favorites="1"))["total"], 1) + + async def test_cursor_pages_walk_only_favorites_in_every_order(self): + self.make("a.png", "b.png", "c.png", "d.png", "e.png") + for name in ("a.png", "c.png", "e.png"): + await self.star(name) + expected = { + "newest": ["e.png", "c.png", "a.png"], + "oldest": ["a.png", "c.png", "e.png"], + "name": ["a.png", "c.png", "e.png"], + "name-desc": ["e.png", "c.png", "a.png"], + "rating": ["e.png", "c.png", "a.png"], + } + for sort, names in expected.items(): + seen, cursor = [], None + while True: + params = {"favorites": "1", "sort": sort, "limit": "1"} + if cursor: + params["cursor"] = cursor + data = await self.page(**params) + self.assertEqual(data["total"], 3, sort) + seen += [i["path"] for i in data["images"]] + cursor = data["next_cursor"] + if not data["has_more"]: + break + self.assertEqual(seen, names, sort) + # A cursor is bound to its filters: a whole-listing cursor is refused. + whole = await self.page(limit="1") + await self.page(400, favorites="1", cursor=whole["next_cursor"]) + + async def test_favorites_follow_their_own_root(self): + second = self.comfy_dir / "second" + make_png(second / "a.png") + make_png(self.output_dir / "a.png") + self.api._get_all_output_dirs = lambda: [self.output_dir, second] + await self.star("a.png", root_index=1) + data = await self.page(favorites="1") + self.assertEqual( + [(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")] + ) + self.assertEqual((await self.page(favorites="1", root="0"))["total"], 0) + self.assertEqual((await self.page(favorites="1", root="1"))["total"], 1) + + async def test_windows_case_folded_favorite_is_listed(self): + make_png(self.output_dir / "Sub" / "A.png") + with patch("py.api.images._path_flavor", ntpath): + await self.star("Sub/A.png") + (self.output_dir / "Sub" / "A.png").rename( + self.output_dir / "Sub" / "a.png" + ) + (self.output_dir / "Sub").rename(self.output_dir / "sub") + self.api.invalidate_gallery_cache() + data = await self.page(favorites="1") + self.assertEqual([i["path"] for i in data["images"]], ["sub/a.png"]) + + async def test_a_favorite_whose_file_is_gone_is_not_listed(self): + self.make("a.png", "b.png") + await self.star("a.png") + await self.star("b.png") + (self.output_dir / "b.png").unlink() + self.api.invalidate_gallery_cache() + data = await self.page(favorites="1") + self.assertEqual([i["path"] for i in data["images"]], ["a.png"]) + self.assertEqual(data["total"], 1) + + async def test_rejects_other_values(self): + for value in ("true", "2", "", "yes"): + data = await self.page(400, favorites=value) + self.assertFalse(data["success"]) + + async def test_store_failure_is_an_error_not_an_unfiltered_page(self): + self.make("a.png") + + def broken(*_args, **_kwargs): + raise sqlite3.OperationalError("database is locked") + + self.api.db.get_root_favorites = broken + data = await self.page(500, favorites="1") + self.assertFalse(data["success"]) + self.assertNotIn("images", data) + + class TestImageFavoriteSchema(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() @@ -314,6 +442,10 @@ class TestImageFavoriteSchema(unittest.TestCase): db.get_image_favorites("output", ["a.png", "b.png"]), {"a.png"} ) self.assertEqual(db.get_image_favorites("output", []), set()) + # One root's favorites, read through the primary key. + self.assertEqual(db.get_root_favorites("output"), {"a.png"}) + self.assertEqual(db.get_root_favorites("other"), {"a.png"}) + self.assertEqual(db.get_root_favorites("missing"), set()) many = [f"{n}.png" for n in range(2000)] + ["a.png"] self.assertEqual(db.get_image_favorites("output", many), {"a.png"}) self.assertTrue(db.clear_image_favorite("output", "a.png")) diff --git a/tests/test_saved_views.py b/tests/test_saved_views.py index 568c276..a0a4e7e 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -35,6 +35,7 @@ IMAGES_STATE = { "folder": "", "media": "all", "tags": ["fluffy"], + "favorites": False, } LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []} @@ -145,6 +146,14 @@ class TestSavedViewsApi(ImageAPITestCase): {**good, "state": {**IMAGES_STATE, "tags": ["a,b"]}}, {**good, "state": {**IMAGES_STATE, "tags": ["x" * 51]}}, {**good, "state": {**IMAGES_STATE, "tags": [f"t{n}" for n in range(21)]}}, + # Favorites (the Favorites page's filter) is a JSON boolean. + {**good, "state": {**IMAGES_STATE, "favorites": "true"}}, + {**good, "state": {**IMAGES_STATE, "favorites": 1}}, + {**good, "state": {**IMAGES_STATE, "favorites": None}}, + { + **good, + "state": {k: v for k, v in IMAGES_STATE.items() if k != "favorites"}, + }, # Library has no source, folder or media, and its own sorts. {"page": "library", "name": "L", "state": IMAGES_STATE}, { @@ -185,10 +194,12 @@ class TestSavedViewsApi(ImageAPITestCase): for sort in ("newest", "oldest", "name", "name-desc", "rating"): for media in ("all", "image", "video"): await self.create(state={"sort": sort, "media": media, "source": "3"}) + favorite = await self.create(state={"favorites": True}) + self.assertIs(favorite["state"]["favorites"], True) for sort in ("newest", "oldest", "name", "rating"): await self.create(page="library", state={"sort": sort, "tags": ["a b"]}) _, listed = await self.send("GET") - self.assertEqual(len(listed["views"]), 19) + self.assertEqual(len(listed["views"]), 20) async def test_rename_and_delete_validate_id_and_body(self): view = await self.create() diff --git a/web/js/workspace/images-state.mjs b/web/js/workspace/images-state.mjs index d51e3f7..4c5b84b 100644 --- a/web/js/workspace/images-state.mjs +++ b/web/js/workspace/images-state.mjs @@ -36,6 +36,10 @@ function queryFor(filters, batchSize) { const sort = filters.sort ?? 'newest'; if (!IMAGE_SORTS.includes(sort)) throw new TypeError('Invalid sort filter'); query.set('sort', sort); + // The Favorites page (#249 D51): the listing keeps only favorites. + const favorites = filters.favorites ?? false; + if (typeof favorites !== 'boolean') throw new TypeError('Invalid favorites filter'); + if (favorites) query.set('favorites', '1'); return query.toString(); } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 096873a..a421c7f 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -96,14 +96,23 @@ function deliverFavorite(key, favorite) { else if (lastVisit?.state) lastVisit = {...lastVisit, state: withItem(lastVisit.state, key, {favorite})}; for (const target of embeddedFavoriteTargets) target(key, favorite); } +// Told after every confirmed favorite change, wherever it was made (the shell's Favorites count, #249 D51). +const favoriteListeners = new Set(); +/** Run `listener()` after each confirmed favorite change; returns the unsubscribe. */ +export function onFavoriteChange(listener) { + favoriteListeners.add(listener); + return () => favoriteListeners.delete(listener); +} /** * An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no * 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. */ export function mountImages(main, api, parentScope, {embedded = false, fetchPage, emptyText, focusTarget = null, savedState = null, - onSaveView = () => {}} = {}) { + favorites = false, onSaveView = () => {}} = {}) { const scope = createScope(); const dispose = () => scope.dispose(); parentScope.signal.addEventListener('abort', dispose, {once:true}); @@ -117,7 +126,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.cleanup(() => inspection.dispose()); scope.cleanup(() => detailInspector?.dispose()); const saved = embedded || !savedState ? null : imagesFilters(savedState); - const visit = embedded || saved ? null : lastVisit; + const visit = embedded || saved || favorites ? null : lastVisit; let density = visit?.density || 'comfortable'; let metrics = {...INITIAL_METRICS}; let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; @@ -126,14 +135,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage let roots = [], folders = []; const rendered = new Map(); const itemIndexes = new Map(); - const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved }; + const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters, ...saved, + favorites: favorites || saved?.favorites === true }; 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. let savedScopePending = Boolean(saved) && (filters.root !== 'all' || filters.subfolder !== ''); scope.cleanup(() => { // A Set category write still in flight may change what the saved collection shows: the return reloads instead. - if (!embedded) lastVisit = state && !bar.writing() ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked: bar.checked(), scrollY: window.scrollY } : null; + // A favorites-only collection is not the Images collection, so it never becomes the Images return state. + if (!embedded && !filters.favorites) 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'); @@ -370,6 +381,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage favoritePending.delete(key); deliverFavorite(key, saved ? favorite : !favorite); showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]); + // On Favorites an unstarred card stays (with ☆) until the next load, so the star can be undone in place (D51). + if (saved) for (const listener of favoriteListeners) listener(); } scope.listen(grid, 'click', event => { const card = event.target.closest('article[data-image-key]'); diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 9f7b507..5068f3a 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -1,16 +1,16 @@ import '../api-client.js'; import { createButton, createScope } from '../shared/toolkit/core.js'; import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts, - LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs'; + LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES, FAVORITE_WRITE_COUNTS } from './state.mjs'; import { mountWorkspaceTheme } from './theme.mjs'; -import { mountImages } from './images.mjs'; +import { mountImages, onFavoriteChange } from './images.mjs'; import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs'; import { mountVocabulary } from './vocabulary.mjs'; import { createWorkspaceIcon } from './icons.mjs'; import { bindCanvasLink, canvasHref } from './topbar.mjs'; -import { renderHeading } from './heading.mjs'; +import { renderHeading, pageHeading } from './heading.mjs'; import { closeWorkspaceDialog } from './workspace-dialogs.mjs'; -import { createSavedViews, openSaveViewDialog, savedViewHash } from './saved-views.mjs'; +import { createSavedViews, openSaveViewDialog, openEditViewDialog, savedViewHash } from './saved-views.mjs'; const { createApiClient, resolveUrl } = window.ApiClient; @@ -73,10 +73,13 @@ function mountWorkspace() { } }; // SAVED VIEWS (#249 D16): the concept's saved-nav rows (search icon, name, no count), loaded with the shell and - // re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. + // re-read after every change. /* D49 */ no "PROPOSED" badge; /* D50 */ no "Needs review" row. The first row is + // the concept's Favorites (star icon, count), kept across every re-read so its count stays (D51). + const [favoritesLink] = navLinks([['favorites', 'star']]); + savedNav.replaceChildren(favoritesLink); const savedViews = createSavedViews(api, scope.signal); scope.cleanup(savedViews.subscribe(({ views }) => { - savedNav.replaceChildren(...views.map(view => { + savedNav.replaceChildren(favoritesLink, ...views.map(view => { const link = element('a'); link.append(createWorkspaceIcon('search'), document.createTextNode(view.name)); link.href = savedViewHash(view.id); @@ -99,7 +102,7 @@ function mountWorkspace() { // Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale. loadNavCounts(api, request.signal, (_, count) => { if (request.signal.aborted) return; - const link = primaryNav.querySelector(`[data-route="${id}"]`); + const link = sidebar.querySelector(`nav [data-route="${id}"]`); link?.querySelector('.nav-count')?.remove(); if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count')); }, [id]); @@ -108,6 +111,8 @@ function mountWorkspace() { scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); }); // A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read. scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS))); + // Starring or unstarring anywhere (Images, Favorites, Library media) re-reads the Favorites count (D51). + scope.cleanup(onFavoriteChange(() => refreshCounts(FAVORITE_WRITE_COUNTS))); refreshCounts(NAV_COUNT_ROUTES); let viewScope; let firstRender = true; @@ -143,6 +148,7 @@ function mountWorkspace() { if (saved) { const page = routes.find(item => item.id === saved.page); renderPage({...page, label:saved.name, title:saved.name}, view, saved); + mountEditAction(saved, view); return; } if (savedViews.status === 'ready') { @@ -161,6 +167,33 @@ 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) { + const heading = pageHeading(main); + if (!heading) return; + const edit = createButton('Edit'); + heading.actions.prepend(edit); + view.listen(edit, 'click', () => openEditViewDialog({ scope: view, trigger: edit, view: saved, + onRename: async name => { + const renamed = await savedViews.rename(saved.id, name); + if (view.signal.aborted) return; + saved = renamed; + retitle(renamed.name); + }, + onDelete: async () => { + await savedViews.remove(saved.id); + window.location.replace(`#/${saved.page}`); + } })); + } + + function retitle(name) { + const { h1 } = pageHeading(main) ?? {}; + if (h1) h1.textContent = name; + document.getElementById('workspace-breadcrumb').textContent = name; + document.title = `${name} · Prompt Manager`; + } + function renderPage(route, view, saved = null) { document.title = `${routeTitle(route)} · Prompt Manager`; // The concept's heading(); each view fills its description and actions. @@ -169,9 +202,10 @@ function mountWorkspace() { if (!firstRender) h1.focus(); firstRender = false; document.getElementById('workspace-breadcrumb').textContent = routeTitle(route); - if (route.id === 'images') { + if (route.id === 'images' || route.id === 'favorites') { mountNewPromptAction(main, api, view); - images = mountImages(main, api, view, {savedState:saved?.state, onSaveView:saveView('images', view)}); + images = mountImages(main, api, view, {savedState:saved?.state, favorites:route.id === 'favorites', + onSaveView:saveView('images', view)}); return; } if (route.id === 'library') { diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index f9f89e0..6ebb645 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -7,13 +7,20 @@ * - The sidebar SAVED VIEWS group lists each view (concept `nav()` a331–335) as `#/saved/` (a1158–1162). * - A view's state always holds every key of its page (the API requires them all); a value its page no longer * accepts is dropped to that key's default, so an opened view applies what it can. + * - D51: an open view's heading Edit (maintainer ruling, beyond the concept): "Edit saved view" with "View name", + * Delete… (danger), Cancel and Save; Delete… asks inside the same dialog, never through `window.confirm`. */ -import { openInputDialog } from './workspace-dialogs.mjs'; +import { createButton } from '../shared/toolkit/core.js'; +import { openInputDialog, openContentDialog, closeWorkspaceDialog } from './workspace-dialogs.mjs'; import { showWorkspaceToast } from './toast.mjs'; export const SAVED_VIEWS_URL = '/prompt_manager/saved-views'; export const SAVED_TOAST = 'Saved view added to the sidebar.'; export const SAVE_FAILED_TOAST = 'The view could not be saved. Try again.'; +export const RENAMED_TOAST = 'Saved view renamed.'; +export const DELETED_TOAST = 'Saved view deleted.'; +export const RENAME_FAILED_TOAST = 'The view could not be renamed. Try again.'; +export const DELETE_FAILED_TOAST = 'The view could not be deleted. Try again.'; const MAX_NAME_LENGTH = 100; const MAX_TAGS = 20; const MAX_TAG_LENGTH = 50; @@ -29,6 +36,8 @@ const FIELDS = Object.freeze({ q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'name-desc', 'rating'], 'newest'), source: { fallback: 'all', accepts: value => typeof value === 'string' && /^(all|0|[1-9][0-9]{0,8})$/.test(value) }, folder: text(1024), media: choice(['all', 'image', 'video'], 'all'), tags, + // A view saved on Favorites (#249 D51) reopens Images filtered to favorites. + favorites: { fallback: false, accepts: value => typeof value === 'boolean' }, }), library: Object.freeze({ q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags, @@ -73,11 +82,12 @@ export const savedViewHash = id => `#/saved/${id}`; /** Images filters (images.mjs `filters`) from a saved state, and the state of the current filters. */ 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 }; + 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 }; } 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 }); + folder: filters.subfolder, media: filters.media, tags: filters.tags, favorites: filters.favorites === true }); } /** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */ @@ -91,7 +101,7 @@ export function libraryViewState(filters) { /** * The shell's list of saved views: `load()` re-reads it (a newer read supersedes an older one), `create()` saves one - * and re-reads the list, `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no + * and re-reads the list, `rename(id, name)` and `remove(id)` change one and re-read it (a failure re-reads it too), `subscribe(fn)` reports `{views, status}` on every change, and `settled()` resolves once no * read is in flight. `status` is 'loading', 'ready' or 'error'; a failed read keeps the last listed views. */ export function createSavedViews(api, signal) { @@ -132,8 +142,36 @@ export function createSavedViews(api, signal) { await load(); return view; } + /** Run one change of a listed view, then re-read the list whatever its outcome (another tab may have changed it). */ + async function change(send) { + try { return await send(); } finally { await load(); } + } + const viewUrl = id => { + if (!Number.isSafeInteger(id) || id < 1) throw new TypeError('Invalid saved view id'); + return `${SAVED_VIEWS_URL}/${id}`; + }; + function rename(id, name) { + const url = viewUrl(id); + return change(async () => { + const view = parseSavedView((await api.patch(url, { name }, { signal }))?.view); + if (!view) throw new TypeError('Invalid saved view'); + return view; + }); + } + function remove(id) { + const url = viewUrl(id); + return change(async () => { + 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; + } + return id; + }); + } return { - load, create, + load, create, rename, remove, get views() { return views; }, get status() { return status; }, find: id => views.find(view => view.id === id) ?? null, @@ -160,3 +198,66 @@ 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, 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. + */ +export function openEditViewDialog({ scope, trigger, view, onRename, onDelete }) { + const input = document.createElement('input'); + input.value = view.name; + input.maxLength = MAX_NAME_LENGTH; + const remove = createButton('Delete…', 'danger'); + const cancel = createButton('Cancel'); + const save = createButton('Save', 'ca-primary'); + const field = document.createElement('div'); + field.className = 'field'; + const label = document.createElement('label'); + label.textContent = 'View name'; + label.htmlFor = input.id = 'action-input'; + field.append(label, input); + const actions = document.createElement('div'); + actions.className = 'dialog-actions'; + actions.append(remove, cancel, save); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + save.addEventListener('click', async () => { + const name = input.value.trim(); + if (!name) { input.focus(); return; } + closeWorkspaceDialog(); + if (name === view.name) return; + try { + await onRename(name); + showWorkspaceToast(RENAMED_TOAST); + } catch { + if (!scope.signal.aborted) showWorkspaceToast(RENAME_FAILED_TOAST); + } + }); + remove.addEventListener('click', () => confirmDelete({ scope, trigger, view, onDelete })); + openContentDialog({ scope, trigger, title: 'Edit saved view', nodes: [field, actions] }); + input.focus(); + input.select(); +} + +function confirmDelete({ scope, trigger, view, onDelete }) { + const text = document.createElement('p'); + text.textContent = `Delete “${view.name}”? Only the saved view is removed; its images and prompts stay.`; + const actions = document.createElement('div'); + actions.className = 'dialog-actions'; + const cancel = createButton('Cancel'); + const confirmButton = createButton('Delete', 'danger'); + actions.append(cancel, confirmButton); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + confirmButton.addEventListener('click', async () => { + closeWorkspaceDialog(); + try { + await onDelete(); + showWorkspaceToast(DELETED_TOAST); + } catch { + showWorkspaceToast(DELETE_FAILED_TOAST); + } + }); + openContentDialog({ scope, trigger, title: 'Delete saved view', nodes: [text, actions] }); + cancel.focus(); +} diff --git a/web/js/workspace/state.mjs b/web/js/workspace/state.mjs index af16b3d..b5c6568 100644 --- a/web/js/workspace/state.mjs +++ b/web/js/workspace/state.mjs @@ -9,11 +9,13 @@ export const routes = [ { id: 'inspect', label: 'Inspect a file', href: '/prompt_manager/gallery.html', description: 'Inspect generation metadata from an image or video file.', action: 'Open existing metadata inspector' }, { id: 'overview', label: 'Overview' }, + // SAVED VIEWS' first row (#249 D51): Images filtered to favorites. No href, so Overview lists no card for it. + { id: 'favorites', label: 'Favorites' }, { id: 'system', label: 'System', href: '/prompt_manager/admin', description: 'Open the current dashboard, then choose Settings or Maintenance for configuration, scans, backups and diagnostics.', action: 'Open existing administration' }, ]; -const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved']); +const NATIVE_ROUTES = new Set(['images', 'library', 'tags', 'overview', 'saved', 'favorites']); /** 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' }); @@ -47,14 +49,16 @@ export function summarizeStats(stats) { }); } -// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. +// The sidebar's real counts (D27): one bounded `limit=1` read each, whose `total` is the count. Favorites (D51) is the +// Favorites page's own listing, so its count is what that page lists. const NAV_COUNT_SOURCES = Object.freeze([ ['images', '/prompt_manager/images/output?mode=workspace&limit=1'], + ['favorites', '/prompt_manager/images/output?mode=workspace&favorites=1&limit=1'], ['library', '/prompt_manager/library?limit=1'], ['tags', '/prompt_manager/vocabulary/tags?limit=1'], ]); -/** Route id -> count for the WORKSPACE links; null when a page is missing or its total is not a count. */ +/** Route id -> count for the sidebar links; null when a page is missing or its total is not a count. */ export function summarizeNavCounts(pages = {}) { return new Map(NAV_COUNT_SOURCES.map(([id]) => { const total = pages?.[id]?.total; @@ -63,16 +67,19 @@ export function summarizeNavCounts(pages = {}) { } /** - * Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read - * settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts. + * Read the counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read settles, so a + * slow listing (the Images output scan) never holds back the cheap Library and Tags counts. */ -/** The WORKSPACE routes that carry a count. */ +/** The sidebar routes that carry a count (WORKSPACE, plus SAVED VIEWS' Favorites). */ export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id)); /** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */ export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']); -/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */ +/** The count a confirmed favorite change can change. */ +export const FAVORITE_WRITE_COUNTS = Object.freeze(['favorites']); + +/** Read the given sidebar counts (all by default), reporting each as its own read settles. */ export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) { const pages = {}; await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {