diff --git a/TESTING.md b/TESTING.md index 8155f912..bc086bee 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2157,10 +2157,35 @@ Recorded deviations on this page: view" filters are active, and plain "Filters" otherwise; the text is its accessible name, so the count is spoken. The concept has no indicator, and without one a Tags filter set by Tags & categories Browse was invisible on the shelf. The Library counts - Tags; Images counts Source, Folder, Media and Tags (each once). The toolbar category is + Tags, Folder and Tag match (D150); Images counts Source, Folder, Media and Tags (each once). The toolbar category is not a Refine filter and is not counted. The caption's count is a `role="status"` region, written only when it changes and kept shown while the shelf reloads, so Load more and scrolling never re-announce it. +- **D150** (#444, cutover #334): "Refine this view" on the Library holds the concept + Filters dialog's **Folder** ("All output folders", then each folder below an output + root indented by depth, read from `GET /prompt_manager/library/options?include=folders` + when the dialog opens; the chosen folder stays listed even when the list lacks it) and + **Tags**, plus **Tag match** ("All of these tags" / "Any of these tags") drawn as the + concept's own select; the concept's "Prompt evidence" and prototype note are absent. + `GET /prompt_manager/library` takes `folder` (`/`-joined, `\` read as `/`; `.`, `..`, + empty segments and drives are 400) and `tag_mode` (`all` default, `any`), and echoes + both; a client whose non-default value is not echoed (an un-restarted older server) + shows the read error, never the unfiltered shelf. A folder keeps a parent with one + linked image (`generated_images.prompt_id`) in it or below it under any spelling of + any output root (the root as configured or detected and after `realpath`, so a + symlinked output folder works): `file_path` (`normcase(normpath(abspath))`) is + compared with prefixes built by the same normalization, so case folding is the + platform's own, including non-ASCII on Windows. The folder is an outer AND around the + parent and pair arms; Any puts one `t.name IN (...)` in each arm, so a card matches + when the parent or one active pair carries any tag (text and category still AND + inside each arm); with one tag Any equals All. Filters counts Tags, Folder and a Tag + match other than All (D44). Saved Library views store `tag_mode` (`all|any`) and + `folder` (text 1024); older rows read the defaults. Tests: + `tests/test_library_folders.py` (pure prefixes and names with `posixpath`/`ntpath`, + drive, UNC, non-ASCII, symlinked root, the any-tag union), `test_api_library.py`, + `test_saved_views.py`, the library-state and saved-views JS units, and + `tests/browser/workspace-library-filters.spec.mjs` (behavior, the Refine dialog against + the concept Filters dialog, artifact captures). Windows: run `portability.yml` on the PR. `tests/browser/library-concept.spec.mjs` compares the shelf (#249 E3) on the concept-shaped fixture, which creates the concept's prompts last to first so Recently @@ -6036,8 +6061,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes). `#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no longer hands off to the legacy tag manager. That page stays reachable from Overview's -"Open existing tag manager" card for tag search and sort, the untagged entry and -multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the +"Open existing tag manager" card for tag search and sort and the untagged entry +(multi-tag OR moved to the Library's Tag match with D150, #444); bulk prompt actions (delete, tag, category) stay on the legacy admin page. A prompt's category is changed in the Prompt library editor or with the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged); the Images selection bar's Set category sets images' own categories only (D140, #332), diff --git a/database/library.py b/database/library.py index d91677be..f57a9d77 100644 --- a/database/library.py +++ b/database/library.py @@ -5,6 +5,7 @@ import sqlite3 from collections.abc import Iterator from contextlib import contextmanager +from .library_folders import folder_condition, list_library_folders from .library_tokens import library_edit_token _ELIGIBLE = """(EXISTS(SELECT 1 FROM prompt_pairs ep WHERE ep.positive_prompt_id=p.id) @@ -121,7 +122,44 @@ def _archive(value: str) -> str: return ARCHIVE_SCOPES[value] -def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list]: +# How the Tags filter matches (#444): every tag (AND) or any of them (OR). +TAG_MODES = ("all", "any") + + +def _tag_mode(tag_mode: object) -> str: + if not isinstance(tag_mode, str) or tag_mode not in TAG_MODES: + raise ValueError("tag_mode must be one of " + ", ".join(TAG_MODES)) + return tag_mode + + +def _tag_conditions(tags: list[str], any_tag: bool) -> tuple[list, list, list]: + """Parent-arm and pair-arm tag conditions and the tags' parameters.""" + parent_exists = ( + "EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.prompt_id=p.id AND t.name{test})" + ) + pair_exists = ( + "EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id " + "WHERE pt.pair_id=pp.id AND t.name{test})" + ) + if any_tag and tags: + # One condition per arm: the card, or one active pair, carries any tag. + test = " IN (" + ",".join("?" for _ in tags) + ")" + return [parent_exists.format(test=test)], [pair_exists.format(test=test)], tags + return ( + [parent_exists.format(test="=?") for _ in tags], + [pair_exists.format(test="=?") for _ in tags], + tags, + ) + + +def _filter( + text: str, + category: str | None, + tags: list[str], + tag_mode: str = "all", + folder_prefixes: list[str] | None = None, +) -> tuple[str, list]: if not isinstance(text, str) or len(text) > 500: raise ValueError("Search text must be a string of at most 500 characters") if category is not None and (not isinstance(category, str) or len(category) > 200): @@ -146,17 +184,13 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list pair.append("pp.category=?") parent_params.append(category) pair_params.append(category) - for tag in tags: - parent.append( - "EXISTS(SELECT 1 FROM prompt_tags pt JOIN tags t ON t.id=pt.tag_id " - "WHERE pt.prompt_id=p.id AND t.name=?)" - ) - pair.append( - "EXISTS(SELECT 1 FROM prompt_pair_tags pt JOIN tags t ON t.id=pt.tag_id " - "WHERE pt.pair_id=pp.id AND t.name=?)" - ) - parent_params.append(tag) - pair_params.append(tag) + parent_tags, pair_tags, tag_params = _tag_conditions( + tags, _tag_mode(tag_mode) == "any" + ) + parent.extend(parent_tags) + pair.extend(pair_tags) + parent_params.extend(tag_params) + pair_params.extend(tag_params) condition = ( "(" + (" AND ".join(parent) or "1=1") @@ -168,7 +202,13 @@ def _filter(text: str, category: str | None, tags: list[str]) -> tuple[str, list + " AND ".join(pair) + ")" ) - return f"({_ELIGIBLE}) AND ({condition})", [*parent_params, *pair_params] + params = [*parent_params, *pair_params] + if folder_prefixes: + # A whole-card condition around both arms: images link to the parent. + folder, folder_params = folder_condition(folder_prefixes) + condition = f"({condition}) AND {folder}" + params.extend(folder_params) + return f"({_ELIGIBLE}) AND ({condition})", params class LibraryOperations: @@ -251,6 +291,8 @@ class LibraryOperations: text: str = "", category: str | None = None, tags: list[str] | None = None, + tag_mode: str = "all", + folder_prefixes: list[str] | None = None, sort: str = "newest", limit: int = 40, after: dict | None = None, @@ -258,7 +300,9 @@ class LibraryOperations: _limit(limit) if sort not in _ORDERS: raise ValueError("Unknown library sort order") - where, params = _filter(text, category, [] if tags is None else tags) + where, params = _filter( + text, category, [] if tags is None else tags, tag_mode, folder_prefixes + ) if after is not None: if ( not isinstance(after, dict) @@ -311,6 +355,8 @@ class LibraryOperations: "items": rows, "total": total, "variant_total": variant_total, + # Echoed so a client can tell this read applied its tag match. + "tag_mode": tag_mode, "has_more": has_more, "next_key": next_key, } @@ -321,6 +367,8 @@ class LibraryOperations: text: str = "", category: str | None = None, tags: list[str] | None = None, + tag_mode: str = "all", + folder_prefixes: list[str] | None = None, sort: str = "newest", limit: int = 40, after: dict | None = None, @@ -333,6 +381,8 @@ class LibraryOperations: text=text, category=category, tags=tags, + tag_mode=tag_mode, + folder_prefixes=folder_prefixes, sort=sort, limit=limit, after=after, @@ -463,9 +513,13 @@ class LibraryOperations: "categories": [row["category"] for row in rows], } - def get_library_options(self) -> dict: + def get_library_options(self, folder_roots=None) -> dict: + """Categories; with *folder_roots* (root spellings) also ``folders``.""" with self.library_snapshot() as (conn, epoch, _ceiling): - return self._get_library_options(conn, epoch) + result = self._get_library_options(conn, epoch) + if folder_roots is not None: + result["folders"] = list_library_folders(conn, folder_roots, _ELIGIBLE) + return result def get_library_previews(self, positive_ids: list[int]) -> dict[int, list[dict]]: """Read at most two links per parent in one bounded page query.""" diff --git a/database/library_folders.py b/database/library_folders.py new file mode 100644 index 00000000..24767957 --- /dev/null +++ b/database/library_folders.py @@ -0,0 +1,111 @@ +"""Library output-folder filter (#444): folder names, root prefixes and listing. + +A folder is a ``/``-joined path relative to an output root, as Images' folder +filter and the legacy ``/prompt_manager/subfolders`` name them. A parent +matches a folder when one of its linked images (``generated_images.prompt_id``, +the link its previews and image count read) lies in that folder or below it, +under any spelling of any output root. + +Matching compares ``generated_images.file_path``, which is always +``normcase(normpath(abspath(path)))`` (``models.normalize_image_path``), with +prefixes built by the same normalization here, so case folding is the +platform's own on both sides (Windows folds non-ASCII too, unlike SQLite's +``lower()``) and the comparison is an exact ``substr`` with no wildcards. +""" + +import os +import sqlite3 + +_MAX_FOLDER = 1024 +_MAX_PREFIXES = 64 + + +def normalize_folder(value: object) -> str: + """The folder as ``a/b``: ``\\`` read as ``/``, outer slashes dropped. + + Empty means no folder filter. ``.``/``..``/empty segments, a drive or + control characters are refused: the value only ever names a folder below a + root, never a path of its own. + """ + if not isinstance(value, str) or len(value) > _MAX_FOLDER: + raise ValueError(f"folder must be text of at most {_MAX_FOLDER} characters") + folder = value.replace("\\", "/").strip("/") + if not folder: + return "" + parts = folder.split("/") + if ( + any(part in ("", ".", "..") for part in parts) + or ":" in parts[0] + or any(ord(char) < 32 or ord(char) == 127 for char in folder) + ): + raise ValueError("folder must be a folder below an output root") + return folder + + +def _base(root: object, pathmod) -> str: + return pathmod.normcase(pathmod.normpath(str(root))) + + +def folder_prefixes(roots, folder: str, pathmod=os.path) -> list[str]: + """One ``file_path`` prefix per root spelling for a normalized folder.""" + prefixes = [] + for root in roots: + joined = pathmod.join(_base(root, pathmod), *folder.split("/")) + prefix = pathmod.normcase(pathmod.normpath(joined)) + pathmod.sep + if prefix not in prefixes: + prefixes.append(prefix) + if not prefixes or len(prefixes) > _MAX_PREFIXES: + raise ValueError("No output folder to filter by") + return prefixes + + +def folder_condition(prefixes: list[str]) -> tuple[str, list]: + """A condition on alias ``p``: one linked image under any prefix.""" + tests = " OR ".join("substr(fg.file_path,1,?)=?" for _ in prefixes) + params = [value for prefix in prefixes for value in (len(prefix), prefix)] + return ( + "EXISTS(SELECT 1 FROM generated_images fg WHERE fg.prompt_id=p.id " + f"AND ({tests}))", + params, + ) + + +def relative_folder(image_path: str, roots, pathmod=os.path) -> str | None: + """The folder of *image_path* below the first root spelling holding it. + + The spelling of the stored path is kept (its case on Windows); ``None`` + for an image directly in a root or outside every root. + """ + directory = pathmod.normpath(pathmod.dirname(image_path)) + folded = pathmod.normcase(directory) + for root in roots: + base = _base(root, pathmod) + start = base if base.endswith(pathmod.sep) else base + pathmod.sep + if not folded.startswith(start): + continue + # normcase keeps the length for ordinary names; else show the folded form. + rest = (directory if len(directory) == len(folded) else folded)[len(start) :] + parts = rest.split(pathmod.sep) + if any(part in ("", ".", "..") for part in parts): + return None + return "/".join(parts) + return None + + +def list_library_folders( + conn: sqlite3.Connection, roots, eligible: str, pathmod=os.path +) -> list[str]: + """Every folder (and ancestor) holding an image of an eligible parent.""" + folders = set() + for (image_path,) in conn.execute( + "SELECT DISTINCT g.image_path FROM generated_images g " + "JOIN prompts p ON p.id=g.prompt_id WHERE " + eligible + ): + if not isinstance(image_path, str) or not image_path: + continue + folder = relative_folder(image_path, roots, pathmod) + if folder is None: + continue + parts = folder.split("/") + folders.update("/".join(parts[:index]) for index in range(1, len(parts) + 1)) + return sorted(folders, key=lambda value: (value.casefold(), value)) diff --git a/database/saved_views.py b/database/saved_views.py index 3a4d7afa..a95ce5fc 100644 --- a/database/saved_views.py +++ b/database/saved_views.py @@ -33,6 +33,8 @@ MAX_TAG_LENGTH = 50 _IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating") _LIBRARY_SORTS = ("newest", "oldest", "name", "rating") _MEDIA = ("all", "image", "video") +# How a Library view's Tags match (#444): every tag or any of them. +_TAG_MODES = ("all", "any") _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 @@ -67,6 +69,9 @@ _STATE_FIELDS = { "category": ("text", 100, ""), "sort": ("choice", _LIBRARY_SORTS, "newest"), "tags": ("tags", None, ()), + # #444: the Tags match and the output folder (relative, "/"-joined). + "tag_mode": ("choice", _TAG_MODES, "all"), + "folder": ("text", 1024, ""), }, } diff --git a/py/api/library.py b/py/api/library.py index 2e3dc4b5..be699e70 100644 --- a/py/api/library.py +++ b/py/api/library.py @@ -21,9 +21,11 @@ from .pairs import pair_response try: from ...database.library import ARCHIVE_SCOPES from ...database.library_commands import LibraryCommandError + from ...database.library_folders import folder_prefixes, normalize_folder except ImportError: from database.library import ARCHIVE_SCOPES from database.library_commands import LibraryCommandError + from database.library_folders import folder_prefixes, normalize_folder _SECRET = os.urandom(32) @@ -283,36 +285,84 @@ class LibraryRoutesMixin: outputs = list(self._get_all_output_dirs()) return self._media_roots(outputs), len(outputs) + def _library_root_spellings(self) -> list[str]: + """Every spelling of each output root (#444 folder filter): the + validated, resolved root and each configured or detected path that + resolves to it, so images saved through a symlinked root match.""" + from ..config import GalleryConfig + + roots = [str(root) for root in self._get_all_output_dirs()] + candidates = [ + *GalleryConfig.MONITORING_DIRECTORIES, + self._find_comfyui_output_dir(), + *self._comfyui_output_dir_list(), + ] + spellings = [] + for root in roots: + for value in (root, *candidates): + if not value or not isinstance(value, (str, os.PathLike)): + continue + value = str(value) + same = value == root or os.path.normcase( + os.path.realpath(value) + ) == os.path.normcase(root) + if same and value not in spellings: + spellings.append(value) + return spellings + @pair_response async def get_library_options(self, request: web.Request) -> web.Response: - result = await self._run_in_executor(self.db.get_library_options) + include = request.query.get("include", "") + if include not in ("", "folders"): + raise ValueError("include must be empty or 'folders'") + if include: + # Folders are read only when Refine opens (a walk of linked paths). + roots = await self._run_in_executor(self._library_root_spellings) + result = await self._run_in_executor(self.db.get_library_options, roots) + else: + result = await self._run_in_executor(self.db.get_library_options) return web.json_response({"success": True, **result}) @pair_response async def list_library(self, request: web.Request) -> web.Response: + folder = normalize_folder(request.query.get("folder", "")) options = { "text": request.query.get("text", ""), "category": request.query.get("category"), "tags": sorted(set(request.query.getall("tag", []))), + "tag_mode": request.query.get("tag_mode", "all"), "sort": request.query.get("sort", "newest"), } limit = _page_limit(request) cursor = request.query.get("cursor") + roots = ( + await self._run_in_executor(self._library_root_spellings) if folder else [] + ) + prefixes = folder_prefixes(roots, folder) if folder else None def read() -> tuple[dict, dict]: with self.db.library_snapshot(include_ceiling=True) as snapshot: conn, epoch, ceiling = snapshot + # The folder and the roots it was resolved under join the scope. scope = _scope( - {"kind": "parents", "projection": 2, "epoch": epoch, **options} + { + "kind": "parents", + "projection": 2, + "epoch": epoch, + **options, + **({"folder": folder, "roots": roots} if folder else {}), + } ) result = self.db._list_library( conn, epoch, ceiling, **options, + folder_prefixes=prefixes, limit=limit, after=_decode(cursor, scope), ) + result["folder"] = folder records = self.db.get_library_previews( [parent["id"] for parent in result["items"]] ) diff --git a/tests/browser/workspace-library-filters.spec.mjs b/tests/browser/workspace-library-filters.spec.mjs new file mode 100644 index 00000000..8a31cee9 --- /dev/null +++ b/tests/browser/workspace-library-filters.spec.mjs @@ -0,0 +1,128 @@ +// #444 (D150): the Library's output-folder filter and multi-tag OR in "Refine this view", against the real server, and +// the Refine dialog against the concept's Filters dialog (app.js:1345-1352). +import {writeFile} from 'node:fs/promises'; +import {startCoverage,saveCoverage} from '../../tools/vendor/browser-coverage.mjs'; +import {test,expect} from './fixtures.mjs'; +import {captureCanonicalState} from './history-canonical-comparison.mjs'; +import {inventoryOf,diffInventory} from './library-editor-canonical-comparison.mjs'; + +const route='/prompt_manager/workspace#/library'; +const concept='/__test__/canonical/workspace'; +const cards=page=>page.locator('article[data-prompt-id]'); +const card=(page,text)=>cards(page).filter({has:page.getByText(text,{exact:true})}); +const toolbar=page=>page.locator('#workspace-main search.toolbar'); +const filtersButton=page=>toolbar(page).getByRole('button',{name:/^Filters(?: · \d)?$/}); +const refine=page=>page.getByRole('dialog',{name:'Refine this view',exact:true}); +const folder=page=>refine(page).getByRole('combobox',{name:'Folder',exact:true}); +const tags=page=>refine(page).getByRole('textbox',{name:'Tags',exact:true}); +const match=page=>refine(page).getByRole('combobox',{name:'Tag match',exact:true}); +const apply=page=>refine(page).getByRole('button',{name:'Apply filters',exact:true}); +const caption=page=>page.locator('#workspace-main .section-caption > span').first(); + +test.beforeEach(async({page,request,browserName})=>{ + await startCoverage(page,browserName);await expect(await request.post('/__test__/reset',{data:{native_library:true}})).toBeOK(); + page.libraryErrors=[];page.on('pageerror',error=>page.libraryErrors.push(error.message)); +}); +test.afterEach(async({page,browserName})=>{await saveCoverage(page,browserName);expect(page.libraryErrors).toEqual([]);}); + +async function open(page) { + await page.goto(route);await expect(caption(page)).toHaveText(/^3 positive prompts · /); +} +/** Open Refine and wait for the folder list it reads on opening. */ +async function openRefine(page) { + const folders=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/library/options'&&new URL(item.url()).searchParams.get('include')==='folders'); + await filtersButton(page).click();await expect(refine(page)).toBeVisible();await folders; +} + +test('Refine offers Folder, Tags and Tag match; a folder narrows the shelf to prompts with images there',async({page})=>{ + await open(page);await openRefine(page); + await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']); + // Both roots hold a "shared" folder; the root's own images name no folder. + await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']); + await expect(match(page).locator('option')).toHaveText(['All of these tags','Any of these tags']); + await folder(page).selectOption('shared');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /); + await expect(cards(page)).toHaveCount(1);await expect(card(page,'Cat portrait')).toHaveCount(1); + await expect(filtersButton(page)).toHaveText('Filters · 1'); + await openRefine(page);await expect(folder(page)).toHaveValue('shared'); + await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click(); + await expect(caption(page)).toHaveText(/^3 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters'); +}); + +test('Tag match Any shows every prompt carrying one of the tags; changing only the match applies it',async({page})=>{ + await open(page);await openRefine(page); + await tags(page).fill('cats, dogs');await apply(page).click(); + await expect(caption(page)).toHaveText(/^0 positive prompts · /);await expect(filtersButton(page)).toHaveText('Filters · 1'); + await openRefine(page);await match(page).selectOption('any');await apply(page).click(); + await expect(caption(page)).toHaveText(/^2 positive prompts · /); + await expect(cards(page)).toHaveCount(2);await expect(card(page,'Cat portrait')).toHaveCount(1);await expect(card(page,'Dog landscape')).toHaveCount(1); + await expect(filtersButton(page)).toHaveText('Filters · 2'); +}); + +test('a saved view keeps its folder and tag match',async({page})=>{ + await open(page);await openRefine(page); + await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /); + await toolbar(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('Shared cats or dogs'); + const created=page.waitForResponse(item=>new URL(item.url()).pathname==='/prompt_manager/saved-views'&&item.request().method()==='POST'); + await save.getByRole('button',{name:'Save',exact:true}).click(); + const body=(await created).request().postDataJSON(); + expect(body.state).toMatchObject({folder:'shared',tag_mode:'any',tags:['cats','dogs']}); + await page.goto('about:blank');await open(page); + await page.getByRole('navigation',{name:'Saved views',exact:true}).getByRole('link',{name:'Shared cats or dogs',exact:true}).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 3'); +}); + +test('a chosen folder missing from the folder list stays listed and chosen in Refine',async({page})=>{ + await open(page);await openRefine(page);await folder(page).selectOption('shared');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /); + // The folder has no image any more as far as the list knows (or the read failed): Apply must not drop it. + await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders:[]}})); + await openRefine(page); + await expect(folder(page).locator('option')).toHaveText(['All output folders','shared']);await expect(folder(page)).toHaveValue('shared'); + await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1'); +}); + +test('an older server that ignores the folder shows an error with Retry, never the whole shelf',async({page})=>{ + await open(page); + await page.route('**/prompt_manager/library?*',async route=>{ + const response=await route.fetch();const body=await response.json();delete body.folder;delete body.tag_mode; + await route.fulfill({response,json:body}); + }); + await openRefine(page);await folder(page).selectOption('shared');await apply(page).click(); + await expect(page.locator('#workspace-main').getByText('Prompt library could not be loaded. Retry.')).toBeVisible(); + await expect(cards(page)).toHaveCount(0); +}); + +// D150: the Refine dialog is the concept's Filters dialog: its Folder select and Tags input, plus Tag match drawn as the +// concept's own select; the concept's "Prompt evidence" (no data yet) and its prototype note are not shown. +function mapFiltersDialog(reference,{folders,matchOptions}) { + const [conceptFolder,,conceptTags]=reference.fields; + return {...reference, + fields:[{...conceptFolder,options:folders},{...conceptTags},{...conceptFolder,label:'Tag match',options:matchOptions}], + small:[], + // Production's Apply reads "Apply filters" (Images and Library share it, #249). + actions:{...reference.actions,buttons:reference.actions.buttons.map(item=>item.text==='Apply tag filter'?{...item,text:'Apply filters'}:item)}}; +} +for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`Refine dialog matches the concept Filters dialog ${width} ${theme} (D150)`,async({page},testInfo)=>{ + await page.setViewportSize({width,height}); + await page.goto('about:blank');await page.goto(`${concept}#library`);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme); + await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible(); + const reference=await page.locator('#modal').evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`concept-filters-${width}-${theme}`,'#modal'); + await open(page);await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);await openRefine(page); + await expect(folder(page).locator('option')).toHaveCount(2); + const actual=await refine(page).evaluate(inventoryOf); + await captureCanonicalState(page,testInfo,`production-refine-${width}-${theme}`,'dialog[open]'); + const expected=mapFiltersDialog(reference,{folders:[['','All output folders'],['shared','shared']],matchOptions:[['all','All of these tags'],['any','Any of these tags']]}); + const path=testInfo.outputPath('refine-inventory.json');await writeFile(path,JSON.stringify({reference,actual},null,2)); + await testInfo.attach('refine-inventory',{path,contentType:'application/json'}); + expect(reference.fields.map(item=>item.label)).toEqual(['Folder','Prompt evidence','Tags']); + expect(diffInventory(expected,actual)).toEqual([]); + await refine(page).getByRole('button',{name:'Close dialog',exact:true}).click(); + await openRefine(page);await folder(page).selectOption('shared');await tags(page).fill('cats, dogs');await match(page).selectOption('any');await apply(page).click(); + await expect(caption(page)).toHaveText(/^1 positive prompt · /);await page.mouse.move(0,0); + await captureCanonicalState(page,testInfo,`production-library-folder-any-${width}-${theme}`,'#workspace-main'); +}); diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 5ef235f3..d4584ee6 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -135,7 +135,8 @@ test('Retry re-reads a failed category list with the shelf',async({page})=>{ await page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true}).click(); await refine(page).getByRole('button',{name:'Apply filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); const search=page.getByRole('searchbox',{name:'Search workspace',exact:true});await search.fill(' '); - await page.waitForTimeout(400);expect(reads).toEqual([]);await expect(cards(page)).toHaveCount(3); + // Only the folder list Refine reads on opening (D150), never the shelf. + await page.waitForTimeout(400);expect(reads).toEqual(['/prompt_manager/library/options']);await expect(cards(page)).toHaveCount(3); }); test('failed library reads retry while late search responses cannot overwrite newer results',async({page})=>{ @@ -168,7 +169,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy // D44: the active Tags filter shows on the button; the category is the toolbar's own and is not counted. await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters · 1']); await toolbar.getByRole('button',{name:'Filters · 1',exact:true}).click(); - await expect(refine(page).locator('.field label')).toHaveText(['Tags']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio'); + await expect(refine(page).locator('.field label')).toHaveText(['Folder','Tags','Tag match']);await expect(refine(page).getByRole('textbox',{name:'Tags',exact:true})).toHaveValue('studio'); await refine(page).getByRole('button',{name:'Clear filters',exact:true}).click();await expect(refine(page)).toHaveCount(0); await expect(toolbar.getByRole('button',{name:'Filters',exact:true})).toBeFocused(); await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('');await expect(cards(page)).toHaveCount(3); diff --git a/tests/browser/workspace-saved-views.spec.mjs b/tests/browser/workspace-saved-views.spec.mjs index b29f16a1..4ca44275 100644 --- a/tests/browser/workspace-saved-views.spec.mjs +++ b/tests/browser/workspace-saved-views.spec.mjs @@ -208,7 +208,7 @@ test('a view stored under older rules still lists and opens with its usable valu await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) }); expect(await listed(request)).toEqual([ { id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } }, - expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [] } }), + expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }), ]); // A state that is no JSON object is never listed, and its link behaves like a missing view. const corruptId = await raw({ page: 'images', name: 'Corrupt', state: '{not json' }); @@ -232,7 +232,7 @@ test('a view stored under older rules still lists and opens with its usable valu test('leaving a saved view returns Images and the Library to their own last state (#249 M2)', async ({ page, request }) => { const dogs = await create(request, { page: 'images', name: 'Dogs', state: { ...IMAGE_DEFAULTS, q: 'dogs' } }); - const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const cats = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); await page.goto(`${workspace}#/images`); await search(page).fill('cat'); await expect(imageCards(page)).toHaveCount(1); @@ -275,7 +275,7 @@ test('a toolbar change while sources load keeps the saved Source and Folder (#24 }); test('a saved view left while its list or rename is pending leaves the next page alone', async ({ page, request }) => { - const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); let releaseList = () => {}; const holdList = async () => { const held = new Promise(resolve => { releaseList = resolve; }); @@ -323,7 +323,7 @@ test('an unknown saved view opens Images, and a failed listing offers Retry', as await expect(page).toHaveURL(/#\/images$/); await expect(main(page).getByRole('heading', { level: 1 })).toHaveText('Images'); - const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [] } }); + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); let failing = true; await page.route(`**${API}`, route => failing && route.request().method() === 'GET' ? route.fulfill({ status: 500, contentType: 'application/json', body: '{"success":false,"error":"down"}' }) @@ -514,7 +514,7 @@ test('Edit renames an open view, and deletes it only after confirming inside the }); 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: [] } }); + const view = await create(request, { page: 'library', name: 'Cats', state: { q: 'cat', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }); await page.goto(`${workspace}#/saved/${view.id}`); await expect(heading(page)).toHaveText('Cats'); const edit = main(page).locator('.heading-actions > button').first(); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 2442364f..4d6f9e3c 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -104,3 +104,17 @@ test('archived variant reads use their own scope and cursor without changing the const active=[];const plain=createLibraryDetail({api:{get:async url=>{active.push(url);return {positive:item(1),variants:[]};}},onChange:()=>{}});await plain.load(1);assert.equal(new URL(active[0],'https://x').searchParams.has('archive'),false); assert.throws(()=>createLibraryDetail({api:{},onChange:()=>{},archive:'parents'})); }); +test('tag mode any and a folder are sent, echoed and validated (#444)',async()=>{ + const {createLibraryStream,LIBRARY_TAG_MODES}=await source;assert.deepEqual(LIBRARY_TAG_MODES,['all','any']); + const calls=[];const pending=[{...page([item(1)]),tag_mode:'any',folder:'2026/Aug'},{...page([item(2)]),tag_mode:'all',folder:''},page([item(3)]),{...page([item(4)]),tag_mode:'all'}]; + const s=createLibraryStream({api:{get:async(url)=>{calls.push(new URL(url,'https://x').searchParams);return pending.shift();}}}); + assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),1); + assert.equal(calls[0].get('tag_mode'),'any');assert.equal(calls[0].get('folder'),'2026/Aug');assert.deepEqual(calls[0].getAll('tag'),['a','b']); + // An older server that ignored them answers without the echo: an error, never the unfiltered shelf. + assert.equal(await s.reset({tags:['a','b'],tagMode:'any',folder:'2026/Aug'}),0);assert.ok(s.state.error instanceof TypeError); + // The defaults send neither, so they need no echo. + assert.equal(await s.reset({tagMode:'all',folder:''}),1);assert.equal(calls[2].has('tag_mode'),false);assert.equal(calls[2].has('folder'),false); + assert.equal(await s.reset({folder:'x'}),0);assert.ok(s.state.error instanceof TypeError,'a folder without its echo is refused'); + await assert.rejects(s.reset({tagMode:'or'}),TypeError);await assert.rejects(s.reset({folder:3}),TypeError); + assert.equal(calls.length,4);s.dispose(); +}); diff --git a/tests/js/workspace-saved-views.test.js b/tests/js/workspace-saved-views.test.js index 46d2fe06..d309390c 100644 --- a/tests/js/workspace-saved-views.test.js +++ b/tests/js/workspace-saved-views.test.js @@ -5,7 +5,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, collection: '' }; -const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [] }; +const LIBRARY_DEFAULTS = { q: '', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' }; test('a saved state always holds every page key and keeps only values its page accepts', async () => { const { normalizeViewState, SAVED_VIEW_PAGES } = await source; @@ -31,8 +31,8 @@ test('a saved state always holds every page key and keeps only values its page a assert.deepEqual(normalizeViewState('images', { ...full, q: 'x'.repeat(501), folder: 7, tags: Array(21).fill('t') }), { ...full, q: '', folder: '', tags: [] }); assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] }); - // Library has no source, folder or media, and no Name Z–A. - assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'] }); + // Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own. + assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' }); assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' }); // The returned tags are a copy. const tags = ['one']; @@ -62,7 +62,7 @@ test('the listing keeps usable views in order and normalizes their state', async { id: 1, page: 'images', name: 'Videos', state: { media: 'video', source: '9' } }, ] }); assert.deepEqual(views, [ - { id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [] } }, + { id: 3, page: 'library', name: 'Neon', state: { q: 'neon', category: '', sort: 'name', tags: [], tag_mode: 'all', folder: '' } }, { id: 1, page: 'images', name: 'Videos', state: { ...IMAGE_DEFAULTS, media: 'video', source: '9' } }, ]); assert.equal(parseSavedView(null), null); @@ -84,8 +84,8 @@ test('page filters and saved states convert both ways', async () => { 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'] }); + const shelf = { text: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tagMode: 'any', folder: '2026' }; + assert.deepEqual(libraryViewState(shelf), { q: 'neon', category: 'Cities', sort: 'oldest', tags: ['night'], tag_mode: 'any', folder: '2026' }); assert.deepEqual(libraryFilters(libraryViewState(shelf)), shelf); }); @@ -215,3 +215,10 @@ test('a state over the API 4 KB cap is refused before anything is sent (#249 M5) await assert.rejects(store.create('images', 'Big', big), error => error instanceof SavedViewTooLarge && error instanceof RangeError); assert.deepEqual(api.calls, []); }); + +test('a Library view keeps its tag match and folder; an old view reads the defaults (#444)', async () => { + const { normalizeViewState, libraryFilters } = await source; + assert.deepEqual(normalizeViewState('library', { tag_mode: 'any', folder: '2026/Aug' }), { ...LIBRARY_DEFAULTS, tag_mode: 'any', folder: '2026/Aug' }); + assert.deepEqual(normalizeViewState('library', { tag_mode: 'untagged', folder: 'x'.repeat(1025) }), LIBRARY_DEFAULTS); + assert.deepEqual(libraryFilters({ q: 'a' }), { text: 'a', category: '', sort: 'newest', tags: [], tagMode: 'all', folder: '' }); +}); diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 0e650972..292f0b83 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -97,6 +97,59 @@ class TestLibraryAPI(ImageAPITestCase): }, ) + async def test_folder_filter_lists_and_matches_prompts_by_output_folder(self): + deep = self._save_prompt("deep") + top = self._save_prompt("top") + other = self._save_prompt("other") + self._save_prompt("no images") + for prompt, parts in ((deep, ("a", "b")), (top, ("a",)), (other, ("ab",))): + path = make_png(self.output_dir.joinpath(*parts, f"{prompt}.png")) + self.api.db.link_image_to_prompt(prompt, str(path)) + response, options = await self.get("/options", include="folders") + self.assertEqual(response.status, 200) + self.assertEqual(options["folders"], ["a", "a/b", "ab"]) + _, plain = await self.get("/options") + self.assertNotIn("folders", plain) + _, first = await self.get(folder="a", limit=1) + self.assertEqual((first["total"], first["folder"]), (2, "a")) + _, second = await self.get(folder="\\a\\", limit=1, cursor=first["next_cursor"]) + self.assertEqual( + sorted([first["items"][0]["text"], second["items"][0]["text"]]), + ["deep", "top"], + ) + _, all_rows = await self.get() + self.assertEqual((all_rows["total"], all_rows["folder"]), (4, "")) + for params in ( + {"folder": "a/../ab"}, + {"folder": "C:/x"}, + {"folder": "b", "cursor": first["next_cursor"]}, + {"cursor": first["next_cursor"]}, + ): + response, result = await self.get(limit=1, **params) + self.assertEqual(response.status, 400, params) + self.assertEqual(result["success"], False) + response, _ = await self.get("/options", include="tags") + self.assertEqual(response.status, 400) + + async def test_any_tag_mode_is_sent_through_and_echoed(self): + self._save_prompt("a only", tags=["a"]) + self._save_prompt("b only", tags=["b"]) + self._save_prompt("neither") + _, result = await self.get(tag=["a", "b"], tag_mode="any") + self.assertEqual(result["tag_mode"], "any") + self.assertEqual( + sorted(i["text"] for i in result["items"]), ["a only", "b only"] + ) + _, both = await self.get(tag=["a", "b"]) + self.assertEqual((both["total"], both["tag_mode"]), (0, "all")) + _, first = await self.get(tag=["a", "b"], tag_mode="any", limit=1) + response, _ = await self.get( + tag=["a", "b"], limit=1, cursor=first["next_cursor"] + ) + self.assertEqual(response.status, 400) + response, _ = await self.get(tag_mode="or") + self.assertEqual(response.status, 400) + async def test_variant_cursor_is_positive_scoped_and_required_limits_are_bounded( self, ): diff --git a/tests/test_library_folders.py b/tests/test_library_folders.py new file mode 100644 index 00000000..6c61b9a9 --- /dev/null +++ b/tests/test_library_folders.py @@ -0,0 +1,196 @@ +"""#444: the Library's output-folder filter and multi-tag OR.""" + +import ntpath +import os +import posixpath +import tempfile +import unittest +from pathlib import Path + +from database.library_folders import ( + folder_prefixes, + normalize_folder, + relative_folder, +) +from database.operations import PromptDatabase +from utils.hashing import generate_prompt_hash + + +class TestFolderNames(unittest.TestCase): + def test_normalize_reads_backslashes_and_strips_outer_slashes(self): + self.assertEqual(normalize_folder(""), "") + self.assertEqual(normalize_folder("/"), "") + self.assertEqual(normalize_folder("2026\\08-Aug\\"), "2026/08-Aug") + self.assertEqual(normalize_folder("/a/b/"), "a/b") + self.assertEqual(normalize_folder("..foo/b"), "..foo/b") + + def test_normalize_refuses_anything_but_a_folder_below_a_root(self): + for value in ("a/../b", "./a", "a//b", "C:/x", "c:", "a\x00b", 3, "x" * 1025): + with self.subTest(value=value), self.assertRaises(ValueError): + normalize_folder(value) + + def test_posix_prefixes_are_exact_and_end_in_a_separator(self): + self.assertEqual( + folder_prefixes(["/out", "/data/out/"], "a/b", posixpath), + ["/out/a/b/", "/data/out/a/b/"], + ) + self.assertEqual(folder_prefixes(["/"], "a", posixpath), ["/a/"]) + self.assertEqual( + folder_prefixes(["/out", "/out/"], "a", posixpath), ["/out/a/"] + ) + with self.assertRaises(ValueError): + folder_prefixes([], "a", posixpath) + + def test_windows_prefixes_fold_case_including_non_ascii(self): + self.assertEqual( + folder_prefixes(["C:\\ComfyUI\\Output"], "Ä/Sub", ntpath), + ["c:\\comfyui\\output\\ä\\sub\\"], + ) + self.assertEqual(folder_prefixes(["D:\\"], "x", ntpath), ["d:\\x\\"]) + self.assertEqual( + folder_prefixes(["\\\\Server\\Share\\Out"], "a", ntpath), + ["\\\\server\\share\\out\\a\\"], + ) + # The stored file_path is normcase(normpath(abspath)), the same fold. + stored = ntpath.normcase(ntpath.normpath("C:/ComfyUI/output/Ä/sub/x.png")) + self.assertTrue( + stored.startswith( + folder_prefixes(["C:/ComfyUI/Output"], "ä/SUB", ntpath)[0] + ) + ) + + def test_relative_folder_keeps_the_stored_spelling(self): + self.assertEqual( + relative_folder("/out/2026/Aug/x.png", ["/out"], posixpath), "2026/Aug" + ) + self.assertIsNone(relative_folder("/out/x.png", ["/out"], posixpath)) + self.assertIsNone(relative_folder("/outside/x.png", ["/out"], posixpath)) + self.assertIsNone(relative_folder("/output2/a/x.png", ["/out"], posixpath)) + self.assertEqual( + relative_folder("/out/..foo/x.png", ["/out"], posixpath), "..foo" + ) + self.assertEqual( + relative_folder("C:\\Out\\Ä\\Sub\\x.png", ["c:\\out"], ntpath), "Ä/Sub" + ) + self.assertEqual(relative_folder("D:\\a\\x.png", ["D:\\"], ntpath), "a") + + +class TestLibraryFolderAndAnyTag(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + base = Path(os.path.realpath(self.temp.name)) + self.root = base / "output" + self.root.mkdir() + self.db = PromptDatabase(str(base / "folders.db")) + + def tearDown(self): + try: + self.db.close_all() + finally: + self.temp.cleanup() + + def save(self, text, **metadata): + return self.db.save_prompt( + text, prompt_hash=generate_prompt_hash(text), **metadata + ) + + def link(self, prompt_id, *parts): + path = self.root.joinpath(*parts) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x") + self.db.link_image_to_prompt(prompt_id, str(path)) + + def texts(self, **options): + return sorted(item["text"] for item in self.db.list_library(**options)["items"]) + + def in_folder(self, folder, roots=None): + return self.texts( + folder_prefixes=folder_prefixes(roots or [str(self.root)], folder) + ) + + def test_folder_matches_its_descendants_only(self): + self.link(self.save("deep"), "a", "b", "x.png") + self.link(self.save("top"), "a", "y.png") + self.link(self.save("sibling"), "ab", "z.png") + self.link(self.save("literal"), "100%_off", "w.png") + self.save("no images") + self.assertEqual(self.in_folder("a"), ["deep", "top"]) + self.assertEqual(self.in_folder("a/b"), ["deep"]) + self.assertEqual(self.in_folder("ab"), ["sibling"]) + self.assertEqual(self.in_folder("100%_off"), ["literal"]) + self.assertEqual(self.in_folder("1"), []) + cut = self.db.list_library( + folder_prefixes=folder_prefixes([str(self.root)], "a"), limit=1 + ) + self.assertEqual(cut["total"], 2) + + def test_folder_combines_with_text_category_and_tags(self): + self.link(self.save("red cat", category="Art", tags=["cat"]), "a", "1.png") + self.link(self.save("red dog", category="Art", tags=["dog"]), "a", "2.png") + self.link(self.save("red bird", category="Art", tags=["cat"]), "b", "3.png") + prefixes = folder_prefixes([str(self.root)], "a") + self.assertEqual( + self.texts(folder_prefixes=prefixes, tags=["cat"]), ["red cat"] + ) + self.assertEqual( + self.texts(folder_prefixes=prefixes, text="dog", category="Art"), + ["red dog"], + ) + + @unittest.skipIf(os.name == "nt", "symlinks need privileges on Windows") + def test_folder_listing_and_match_through_a_symlinked_root(self): + link = self.root.parent / "linked" + os.symlink(self.root, link) + prompt = self.save("via link") + (self.root / "a").mkdir() + (self.root / "a" / "x.png").write_bytes(b"x") + self.db.link_image_to_prompt(prompt, str(link / "a" / "x.png")) + spellings = [str(self.root), str(link)] + self.assertEqual(self.in_folder("a", spellings), ["via link"]) + self.assertEqual(self.in_folder("a", [str(self.root)]), []) + self.assertEqual(self.db.get_library_options(spellings)["folders"], ["a"]) + + def test_folder_listing_offers_ancestors_and_skips_outside_images(self): + self.link(self.save("deep"), "2026", "08-Aug", "x.png") + self.link(self.save("root image"), "y.png") + outside = Path(self.temp.name) / "elsewhere" / "z.png" + outside.parent.mkdir() + outside.write_bytes(b"x") + self.db.link_image_to_prompt(self.save("outside"), str(outside)) + options = self.db.get_library_options([str(self.root)]) + self.assertEqual(options["folders"], ["2026", "2026/08-Aug"]) + self.assertNotIn("folders", self.db.get_library_options()) + + def test_any_tag_mode_is_the_union_across_parent_and_pair(self): + self.save("parent a", tags=["a"]) + self.save("parent b", tags=["b"]) + self.save("both", tags=["a", "b"]) + self.save("neither", tags=["c"]) + self.db.save_prompt_pair("pair b", "noise", tags=["b"]) + split = self.save("split", tags=["a"]) + self.db.save_prompt_pair("split", "split noise", tags=["b"]) + archived = self.db.save_prompt_pair("archived b", "old", tags=["b"]) + self.db.archive_prompt_pair(archived) + self.assertIsInstance(split, int) + self.assertEqual( + self.texts(tags=["a", "b"], tag_mode="any"), + ["both", "pair b", "parent a", "parent b", "split"], + ) + self.assertEqual(self.texts(tags=["a", "b"], tag_mode="all"), ["both"]) + self.assertEqual(self.texts(tags=["a", "b"]), ["both"]) + for tags in (["a"], ["b"], ["c"]): + self.assertEqual( + self.texts(tags=tags, tag_mode="any"), self.texts(tags=tags) + ) + self.assertEqual( + self.texts(tags=["a", "b"], tag_mode="any", text="parent"), + ["parent a", "parent b"], + ) + self.assertEqual(self.texts(tag_mode="any"), self.texts()) + for mode in ("none", None, "ANY"): + with self.subTest(mode=mode), self.assertRaises(ValueError): + self.db.list_library(tag_mode=mode) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_saved_views.py b/tests/test_saved_views.py index 662e7573..0032da54 100644 --- a/tests/test_saved_views.py +++ b/tests/test_saved_views.py @@ -42,7 +42,14 @@ IMAGES_STATE = { "favorites": False, "collection": "", } -LIBRARY_STATE = {"q": "", "category": "", "sort": "rating", "tags": []} +LIBRARY_STATE = { + "q": "", + "category": "", + "sort": "rating", + "tags": [], + "tag_mode": "any", + "folder": "2026/Aug", +} IMAGES_DEFAULTS = { "q": "", "category": "", @@ -54,7 +61,14 @@ IMAGES_DEFAULTS = { "favorites": False, "collection": "", } -LIBRARY_DEFAULTS = {"q": "", "category": "", "sort": "newest", "tags": []} +LIBRARY_DEFAULTS = { + "q": "", + "category": "", + "sort": "newest", + "tags": [], + "tag_mode": "all", + "folder": "", +} # A row saved by an older build: no favorites key, a retired key, a sort # that is no longer offered and an over-long folder. OLD_IMAGES_STATE = json.dumps( @@ -228,7 +242,10 @@ class TestSavedViewsApi(ImageAPITestCase): 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"]}) + await self.create( + page="library", + state={"sort": sort, "tags": ["a b"], "tag_mode": "all", "folder": ""}, + ) _, listed = await self.send("GET") self.assertEqual(len(listed["views"]), 20) @@ -675,6 +692,19 @@ class TestSavedViewsSchema(unittest.TestCase): ) self.assertEqual(normalize_state("history", {}), {}) + def test_library_tag_mode_and_folder_are_kept_with_defaults_for_old_rows(self): + # A row from before #444 has neither key. + old = {"q": "", "category": "", "sort": "rating", "tags": ["a"]} + self.assertEqual(normalize_state("library", old), {**LIBRARY_DEFAULTS, **old}) + self.assertEqual( + normalize_state("library", {"tag_mode": "untagged", "folder": 3}), + LIBRARY_DEFAULTS, + ) + with self.assertRaises(SavedViewInvalid): + encode_state("library", {**LIBRARY_STATE, "tag_mode": "or"}) + with self.assertRaises(SavedViewInvalid): + encode_state("library", {**LIBRARY_STATE, "folder": "x" * 1025}) + def test_validation_errors_are_saved_view_invalid(self): for call in ( lambda: validate_name(""), diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 7ba2b791..3fd7048e 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -12,10 +12,21 @@ function parameters(filters,limit) { for(const tag of filters.tags || []) if(tag.trim()) query.append('tag',tag.trim()); const sort=filters.sort || 'newest'; if(!['newest','oldest','name','rating'].includes(sort)) throw new TypeError('Invalid library sort'); + // #444: how Tags match (every tag or any of them) and the output folder below a root. + const tagMode=filters.tagMode ?? 'all',folder=filters.folder ?? ''; + if(!LIBRARY_TAG_MODES.includes(tagMode)) throw new TypeError('Invalid library tag mode'); + if(typeof folder!=='string' || folder.length>1024) throw new TypeError('Invalid library folder'); + if(tagMode!=='all') query.set('tag_mode',tagMode); + if(folder) query.set('folder',folder); query.set('sort',sort);return query; } +/** The Library's tag match values (#444); the first is the default, sent as no parameter. */ +export const LIBRARY_TAG_MODES=Object.freeze(['all','any']); // `variant_total` is the filtered parents' active negative variants (the concept caption's "M negative variants"). -function validatePage(page,cursor) { +function validatePage(page,cursor,query) { + // A tag match or folder the server did not echo was not applied (an older server): never show the whole shelf as filtered. + if(query?.has('tag_mode') && page?.tag_mode!==query.get('tag_mode')) throw new TypeError('Unconfirmed library tag mode'); + if(query?.has('folder') && page?.folder!==query.get('folder')) throw new TypeError('Unconfirmed library folder'); if(!page || !Array.isArray(page.items) || page.items.some(item=>!parent(item)) || !Number.isSafeInteger(page.total) || page.total<0 || !Number.isSafeInteger(page.variant_total) || page.variant_total<0 || typeof page.has_more!=='boolean' || !(page.next_cursor===null || (typeof page.next_cursor==='string' && page.next_cursor.length)) || page.has_more!==(page.next_cursor!==null) || (page.has_more && cursor===page.next_cursor)) throw new TypeError('Invalid library page'); } export function createLibraryStream({api,onChange=()=>{},restore}) { @@ -37,7 +48,7 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { try { const page=await api.get(`/prompt_manager/library?${params}`,{signal:current.signal}); if(disposed || current!==request) return 0; - validatePage(page,cursor);const incoming=[];const added=new Set(); + validatePage(page,cursor,query);const incoming=[];const added=new Set(); for(const item of page.items) if(!seen.has(item.id) && !added.has(item.id)) {added.add(item.id);incoming.push(Object.freeze({...item}));} for(const value of added) seen.add(value); request=null;emit({items:incoming.length?Object.freeze([...state.items,...incoming]):state.items,total:page.total,variantTotal:page.variant_total,cursor:page.next_cursor,hasMore:page.has_more,loading:false,error:null});return incoming.length; diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 1a263ce5..e687816c 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -2,7 +2,7 @@ import {createScope,createButton} from '../shared/toolkit/core.js'; import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; -import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs'; +import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES} from './library-state.mjs'; import {promptRoles} from './prompt-blocks.mjs'; import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs'; import {createLibraryEditorController} from './library-editor-state.mjs'; @@ -30,6 +30,16 @@ const metadataDrafts=new Map(); const archivedVisible=new Map(); // The concept's three sorts first, then the Library's appended Oldest first (D14). const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['rating','Highest rated'],['oldest','Oldest first']]); +// Refine › Tag match (#444 D150), in LIBRARY_TAG_MODES order. +const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags']]); +/** + * Refine › Folder options (#444 D150): the concept's "All output folders" first, then each folder indented by its depth + * with its last segment as the label (the legacy admin select), and the current folder kept even when it is not listed. + */ +export function libraryFolderOptions(folders,current='') { + const listed=[...folders];if(current && !listed.includes(current)) listed.push(current); + return [['','All output folders'],...listed.map(folder=>{const parts=folder.split('/');return [folder,`${'\u00a0\u00a0'.repeat(parts.length-1)}${parts.at(-1)}`];})]; +} const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.'; const ARCHIVED_MESSAGE='Archived. Find it under Show archived in Negative variants.'; function el(tag,text,className) { @@ -80,9 +90,9 @@ export function onLibraryWrite(listener) { * with the existing Library filters, and the heading takes focus as after a save. */ export function browseLibrary(filters) { - const {text='',category='',tags=[],sort='newest'}=filters || {}; - if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string') throw new TypeError('Invalid Library filters'); - shelfVisit={filters:{text,category,tags:[...tags],sort},anchor:null}; + const {text='',category='',tags=[],sort='newest',tagMode='all',folder=''}=filters || {}; + if(typeof text!=='string' || typeof category!=='string' || !Array.isArray(tags) || tags.some(tag=>typeof tag!=='string') || typeof sort!=='string' || !LIBRARY_TAG_MODES.includes(tagMode) || typeof folder!=='string') throw new TypeError('Invalid Library filters'); + shelfVisit={filters:{text,category,tags:[...tags],sort,tagMode,folder},anchor:null}; focusHeadingOnMount=true;window.location.hash='#/library'; } /** Remember the selection a reconciled save names, so the destination history opens on it once. */ @@ -155,13 +165,14 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const visit=saved?null:shelfVisit, mountedGeneration=writeGeneration; // An invalidated visit keeps only filters and anchor: the stream restarts and heights are measured again. const restorable=visit?.state?visit:null; - const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters,...saved}; + const filters={text:'',category:'',tags:[],sort:'newest',tagMode:'all',folder:'',...visit?.filters,...saved}; const section=el('section',undefined,'library-shelf');section.setAttribute('aria-label','Prompt library'); // The shared concept toolbar (app.js toolbar(), a347–360): Tags live in "Refine this view" (D14), no Refresh (D15). // Its own scope, disposed after the visit is saved: removing it first would shorten the page and clamp scrollY. const toolbarScope=createScope(); - // D44: Filters reads "Filters · 1" while a Tags filter (from Refine or a Browse) narrows the shelf. - const refinements=()=>filters.tags.length?1:0; + // D44: Filters reads "Filters · N" while Refine filters narrow the shelf: Tags, a Folder and a Tag match other + // than All (#444 D150) each count once. + const refinements=()=>(filters.tags.length?1:0)+(filters.folder?1:0)+(filters.tagMode!=='all'?1:0); const toolbar=mountToolbar(section,{scope:toolbarScope,categories:filters.category?[filters.category]:[],sorts:LIBRARY_SORTS, value:{q:filters.text,category:filters.category,sort:filters.sort},activeFilters:refinements(),onChange:applyToolbar,onFilters:openFilters, onSaveView:trigger=>{const value=toolbar.value();onSaveView(trigger,libraryViewState({...filters,text:value.q.trim(),category:value.category,sort:value.sort}));}}); @@ -203,7 +214,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // Nothing follows the grid in the concept; the legacy Library stays one click away on Overview (spec §5). continuation.append(message,retry,more);section.append(optionsStatus,exportStatus,caption,grid,continuation);main.append(section); const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); - let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; + let state,frame=0,loadFrame=0,observer,optionsRequest,folderRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64; let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map(); @@ -402,15 +413,25 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on if(Object.entries(next).every(([key,item])=>filters[key]===item)) return; Object.assign(filters,next);reset(); } - /** "Refine this view" with the Library's one extra filter, Tags (D14); Clear also clears the toolbar category. */ + /** + * "Refine this view" with the Library's filters: the concept's Folder (#444 D150, its options read when the dialog + * opens), Tags (D14) and Tag match (D150); Clear also clears the toolbar category. + */ function openFilters(trigger) { - openRefineDialog({scope,trigger,fields:[{key:'tags'}],values:{tags:filters.tags.join(', ')},onApply:applyRefinement, - onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); + const refine=openRefineDialog({scope,trigger,fields:[{key:'folder',options:libraryFolderOptions([],filters.folder)},{key:'tags'}, + {key:'tagMode',options:TAG_MATCH_OPTIONS}],values:{folder:filters.folder,tags:filters.tags.join(', '),tagMode:filters.tagMode}, + onApply:applyRefinement,onClear:cleared=>{toolbar.setValue({category:cleared.category});applyRefinement(cleared);}}); + folderRequest?.abort();const request=folderRequest=new AbortController(); + // A failed read leaves "All output folders" and the current folder; nothing else depends on it. + api.get('/prompt_manager/library/options?include=folders',{signal:request.signal}).then(data=>{ + if(scope.signal.aborted || folderRequest!==request || !Array.isArray(data?.folders) || data.folders.some(folder=>typeof folder!=='string')) return; + refine.setOptions('folder',libraryFolderOptions(data.folders,filters.folder)); + },()=>{}); } - function applyRefinement({tags,category=filters.category}) { + function applyRefinement({tags,tagMode='all',folder='',category=filters.category}) { const next=[...new Set(tags.split(',').map(tag=>tag.trim()).filter(Boolean))]; - if(category===filters.category && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; - filters.category=category;filters.tags=next;reset(); + if(category===filters.category && tagMode===filters.tagMode && folder===filters.folder && next.length===filters.tags.length && next.every((tag,index)=>tag===filters.tags[index])) return; + filters.category=category;filters.tags=next;filters.tagMode=tagMode;filters.folder=folder;reset(); } scope.listen(more,'click',()=>stream.loadMore()); // Retry also re-reads a category list that failed (D15: Refresh is gone). @@ -423,7 +444,7 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on // A write while mounted makes this stream stale: keep only what still means something. // A saved view is not the plain shelf, so it never replaces the Library return state (#249 M2, as Favorites). if(!saved) shelfVisit=mountedGeneration!==writeGeneration?{filters:savedFilters,anchor:savedAnchor}:state?{filters:savedFilters,state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:savedAnchor}}:null; - toolbarScope.dispose();stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + toolbarScope.dispose();stream.dispose();optionsRequest?.abort();folderRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); loadCategories();if(restorable) {render(stream.state);if(restorable.state.loading || restorable.state.error)stream.loadMore();} else reset(); } diff --git a/web/js/workspace/saved-views.mjs b/web/js/workspace/saved-views.mjs index 7f027860..6619a2b3 100644 --- a/web/js/workspace/saved-views.mjs +++ b/web/js/workspace/saved-views.mjs @@ -46,6 +46,8 @@ const FIELDS = Object.freeze({ }), library: Object.freeze({ q: text(500), category: text(100), sort: choice(['newest', 'oldest', 'name', 'rating'], 'newest'), tags, + // #444: how the Tags match and the output folder ('/'-joined, below a root). + tag_mode: choice(['all', 'any'], 'all'), folder: text(1024), }), }); @@ -112,10 +114,11 @@ export function imagesViewState(filters) { /** Library shelf filters (library.mjs `filters`) from a saved state, and the state of the current filters. */ export function libraryFilters(state) { const view = normalizeViewState('library', state); - return { text: view.q, category: view.category, sort: view.sort, tags: view.tags }; + return { text: view.q, category: view.category, sort: view.sort, tags: view.tags, tagMode: view.tag_mode, folder: view.folder }; } export function libraryViewState(filters) { - return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags }); + return normalizeViewState('library', { q: filters.text, category: filters.category, sort: filters.sort, tags: filters.tags, + tag_mode: filters.tagMode ?? 'all', folder: filters.folder ?? '' }); } /** diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index fff32920..83538cac 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -24,7 +24,8 @@ import { createButton, createSwitch } from '../shared/toolkit/core.js'; import { noun } from './plural.mjs'; -const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags' }); +// `tagMode` (#444, D150): the Library's Tag match select after Tags. +const FIELD_LABELS = Object.freeze({ source: 'Source', folder: 'Folder', media: 'Media', tags: 'Tags', tagMode: 'Tag match' }); const FIELD_ORDER = Object.keys(FIELD_LABELS); let shell = null; @@ -171,7 +172,7 @@ export function openRefineDialog({ scope, fields, values = {}, onApply, onClear, requireScope(scope); requireCallbacks({ onApply, onClear }); if (!Array.isArray(fields) || fields.some(item => !FIELD_ORDER.includes(item?.key))) { - throw new TypeError('Refine fields are source, folder, media and tags'); + throw new TypeError('Refine fields are source, folder, media, tags and tagMode'); } const controls = FIELD_ORDER.flatMap(key => { const item = fields.find(candidate => candidate.key === key);