diff --git a/TESTING.md b/TESTING.md index 2a3993d3..4f126f45 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2448,12 +2448,15 @@ Recorded deviations on this page: 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` + root as a tree built by path segment (each folder directly under its own parent, + case variants as distinct siblings, siblings case-insensitive then exact), 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" / "Prompts without tags", D149) 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`, `untagged` from D149), and echoes + `GET /prompt_manager/library` takes `folder` (`/`-joined; `\` is a separator only on + Windows, on POSIX it is part of a folder's name as the listing shows it; `.`, `..`, + empty segments and, on Windows, drives are 400) and `tag_mode` (`all` default, `any`, `untagged` from D149), 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 diff --git a/database/library_folders.py b/database/library_folders.py index 24767957..48d10f7d 100644 --- a/database/library_folders.py +++ b/database/library_folders.py @@ -20,22 +20,25 @@ _MAX_FOLDER = 1024 _MAX_PREFIXES = 64 -def normalize_folder(value: object) -> str: - """The folder as ``a/b``: ``\\`` read as ``/``, outer slashes dropped. +def normalize_folder(value: object, pathmod=os.path) -> str: + """The folder as ``a/b``, outer slashes dropped; empty means no filter. - 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. + ``/`` always separates. ``\\`` separates only on Windows (``ntpath``): on + POSIX it is an ordinary character of a folder's name, and the listing + (``relative_folder``) names such a folder with it, so both agree. + ``.``/``..``/empty segments, control characters and (on Windows) a drive + are refused: the value only ever names a folder below a root. """ 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("/") + windows = pathmod.sep == "\\" + folder = (value.replace("\\", "/") if windows else value).strip("/") if not folder: return "" parts = folder.split("/") if ( any(part in ("", ".", "..") for part in parts) - or ":" in parts[0] + or (windows and ":" 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") diff --git a/tests/browser/workspace-library-filters.spec.mjs b/tests/browser/workspace-library-filters.spec.mjs index 075f76f5..2dbb7101 100644 --- a/tests/browser/workspace-library-filters.spec.mjs +++ b/tests/browser/workspace-library-filters.spec.mjs @@ -85,6 +85,21 @@ test('a chosen folder missing from the folder list stays listed and chosen in Re await apply(page).click();await expect(caption(page)).toHaveText(/^1 positive prompt · /);await expect(filtersButton(page)).toHaveText('Filters · 1'); }); +// Live B1: the server's flat list sorts full paths as text ("-" and "_" before "/"); Refine shows each folder directly +// under its own parent and keeps case variants apart. +test('the Folder list nests each folder under its exact parent',async({page})=>{ + const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN', + 'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','kiko','kiko-v6','kiko/char'].sort(); + await page.route('**/prompt_manager/library/options?include=folders',route=>route.fulfill({json:{success:true,library_epoch:'x',categories:[],folders}})); + await open(page);await openRefine(page); + const I='\u00a0\u00a0'; + await expect(folder(page).locator('option')).toHaveText(['All output folders','CCC',`${I}mabes9sans-dataset`,`${I}${I}mabes9sans-dataset_IMG_GEN`, + `${I}mabes9sans-dataset-v1`,'FLUX',`${I}2025-12-11`,'flux',`${I}images`,'flux-klein','kiko',`${I}char`,'kiko-v6']); + expect(await folder(page).locator('option').evaluateAll(nodes=>nodes.map(node=>node.value))).toEqual(['','CCC','CCC/mabes9sans-dataset', + 'CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN','CCC/mabes9sans-dataset-v1','FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein', + 'kiko','kiko/char','kiko-v6']); +}); + 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=>{ diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index f81c1a9a..a31063d0 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -145,3 +145,28 @@ test('the shelf no longer sorts by rating (D153)',async()=>{ assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/); for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose(); }); +test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{ + const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function'); + const I='  '; + // The server's flat list sorts full paths as text, so "-" and "_" land before "/" (live report shapes). + const folders=['CCC','CCC/mabes9sans-dataset','CCC/mabes9sans-dataset-v1','CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN', + 'FLUX','FLUX/2025-12-11','flux','flux/images','flux-klein','illustrious','illustrious-base','illustrious/2026-10-26','illustrious/training', + 'illustrious/training/a','kiko','kiko-v6','kiko/char','kiko/XXX','QWEN','QWEN/x','Qwen','Qwen/y','Qwen-Training','Video','Video/v','video'].sort(); + const options=libraryFolderOptions(folders); + assert.deepEqual(options[0],['','All output folders']); + assert.deepEqual(options.slice(1),[ + ['CCC','CCC'],['CCC/mabes9sans-dataset',`${I}mabes9sans-dataset`],['CCC/mabes9sans-dataset/mabes9sans-dataset_IMG_GEN',`${I}${I}mabes9sans-dataset_IMG_GEN`], + ['CCC/mabes9sans-dataset-v1',`${I}mabes9sans-dataset-v1`], + ['FLUX','FLUX'],['FLUX/2025-12-11',`${I}2025-12-11`],['flux','flux'],['flux/images',`${I}images`],['flux-klein','flux-klein'], + ['illustrious','illustrious'],['illustrious/2026-10-26',`${I}2026-10-26`],['illustrious/training',`${I}training`],['illustrious/training/a',`${I}${I}a`], + ['illustrious-base','illustrious-base'], + ['kiko','kiko'],['kiko/char',`${I}char`],['kiko/XXX',`${I}XXX`],['kiko-v6','kiko-v6'], + ['QWEN','QWEN'],['QWEN/x',`${I}x`],['Qwen','Qwen'],['Qwen/y',`${I}y`],['Qwen-Training','Qwen-Training'], + ['Video','Video'],['Video/v',`${I}v`],['video','video'], + ]); + // Every parent precedes its children directly; a chosen folder missing from the list is kept, with its parents. + assert.deepEqual(libraryFolderOptions(['a'],'a/b/c').slice(1),[['a','a'],['a/b',`${I}b`],['a/b/c',`${I}${I}c`]]); + assert.deepEqual(libraryFolderOptions([],''),[['','All output folders']]); + // A POSIX folder whose name holds a backslash is one segment. + assert.deepEqual(libraryFolderOptions(['Metadata\\Klein_4b Wildcard Run']).slice(1),[['Metadata\\Klein_4b Wildcard Run','Metadata\\Klein_4b Wildcard Run']]); +}); diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 3b8da55f..e457a2cd 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -138,7 +138,9 @@ class TestLibraryAPI(ImageAPITestCase): 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"]) + # Outer slashes are dropped (a backslash separates only on Windows). + outer = "\\a\\" if os.name == "nt" else "/a/" + _, second = await self.get(folder=outer, limit=1, cursor=first["next_cursor"]) self.assertEqual( sorted([first["items"][0]["text"], second["items"][0]["text"]]), ["deep", "top"], @@ -147,7 +149,7 @@ class TestLibraryAPI(ImageAPITestCase): self.assertEqual((all_rows["total"], all_rows["folder"]), (4, "")) for params in ( {"folder": "a/../ab"}, - {"folder": "C:/x"}, + *([{"folder": "C:/x"}] if os.name == "nt" else []), {"folder": "b", "cursor": first["next_cursor"]}, {"cursor": first["next_cursor"]}, ): @@ -157,6 +159,17 @@ class TestLibraryAPI(ImageAPITestCase): response, _ = await self.get("/options", include="tags") self.assertEqual(response.status, 400) + @unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows") + async def test_a_posix_folder_name_with_a_backslash_filters_its_prompts(self): + prompt = self._save_prompt("klein") + name = "Metadata\\Klein_4b Wildcard Run" + path = make_png(self.output_dir / name / "x.png") + self.api.db.link_image_to_prompt(prompt, str(path)) + _, options = await self.get("/options", include="folders") + self.assertIn(name, options["folders"]) + _, result = await self.get(folder=name) + self.assertEqual((result["total"], result["folder"]), (1, name)) + 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"]) diff --git a/tests/test_library_folders.py b/tests/test_library_folders.py index 8a5913d0..a49359fa 100644 --- a/tests/test_library_folders.py +++ b/tests/test_library_folders.py @@ -17,17 +17,45 @@ 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_strips_outer_slashes_on_both_flavors(self): + for flavor in (posixpath, ntpath): + with self.subTest(flavor=flavor.__name__): + self.assertEqual(normalize_folder("", flavor), "") + self.assertEqual(normalize_folder("/", flavor), "") + self.assertEqual(normalize_folder("/a/b/", flavor), "a/b") + self.assertEqual(normalize_folder("..foo/b", flavor), "..foo/b") + + def test_a_backslash_separates_only_on_windows(self): + # Live B1: on POSIX a backslash is part of a folder name, as listed. + name = "Metadata\\Klein_4b Wildcard Run" + self.assertEqual(normalize_folder(name, posixpath), name) + self.assertEqual( + normalize_folder("2026\\08-Aug\\", posixpath), "2026\\08-Aug\\" + ) + self.assertEqual( + normalize_folder(name, ntpath), "Metadata/Klein_4b Wildcard Run" + ) + self.assertEqual(normalize_folder("2026\\08-Aug\\", ntpath), "2026/08-Aug") + # A colon names a drive only on Windows. + self.assertEqual(normalize_folder("a:b/c", posixpath), "a:b/c") 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) + for flavor, value in ( + *( + (flavor, value) + for flavor in (posixpath, ntpath) + for value in ("a/../b", "./a", "a//b", "a\x00b", 3, "x" * 1025) + ), + (ntpath, "C:/x"), + (ntpath, "c:"), + (ntpath, "a\\..\\b"), + ): + with self.subTest(flavor=flavor.__name__, value=value): + with self.assertRaises(ValueError): + normalize_folder(value, flavor) + + def test_the_default_flavor_is_the_platform_path_module(self): + self.assertEqual(normalize_folder("a\\b"), normalize_folder("a\\b", os.path)) def test_posix_prefixes_are_exact_and_end_in_a_separator(self): self.assertEqual( @@ -150,6 +178,16 @@ class TestLibraryFolderAndAnyTag(unittest.TestCase): self.assertEqual(self.in_folder("a", [str(self.root)]), []) self.assertEqual(self.db.get_library_options(spellings)["folders"], ["a"]) + @unittest.skipIf(os.name == "nt", "a backslash is a separator on Windows") + def test_a_posix_folder_with_a_backslash_lists_and_filters_alike(self): + name = "Metadata\\Klein_4b Wildcard Run" + self.link(self.save("klein"), name, "x.png") + self.link(self.save("other"), "Metadata", "y.png") + listed = self.db.get_library_options([str(self.root)])["folders"] + self.assertEqual(listed, ["Metadata", name]) + self.assertEqual(self.in_folder(normalize_folder(name)), ["klein"]) + self.assertEqual(self.in_folder(normalize_folder("Metadata")), ["other"]) + 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") diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index d7333926..ebaa2760 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -132,3 +132,28 @@ export function libraryWindow(count,columns,top,height,focused=-1) { if(focused>=0 && focuseda-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index{const x=a.toLowerCase(),y=b.toLowerCase();return xy?1:ab?1:0;}; +/** + * Refine › Folder options (#444 D150): the concept's "All output folders" first, then the folders as a tree built by + * path segment, so each folder sits directly under its own parent (never under a longer sibling such as `kiko-v6`) and + * folders differing only in case stay distinct siblings. Siblings sort case-insensitively, then by exact spelling. A + * label is the last segment indented by depth (the legacy admin select). The chosen folder is kept even when the list + * lacks it, with its parents. + */ +export function libraryFolderOptions(folders,current='') { + const root=new Map(); + for(const folder of current?[...folders,current]:folders) { + let level=root; + for(const segment of folder.split('/')) {if(!level.has(segment)) level.set(segment,new Map());level=level.get(segment);} + } + const options=[['','All output folders']]; + (function walk(level,prefix,depth) { + for(const segment of [...level.keys()].sort(byFolderName)) { + const path=prefix?`${prefix}/${segment}`:segment; + options.push([path,`${'\u00a0\u00a0'.repeat(depth)}${segment}`]); + walk(level.get(segment),path,depth+1); + } + })(root,'',0); + return options; +} diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 98f989b2..712f8fd7 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,LIBRARY_TAG_MODES} from './library-state.mjs'; +import {createLibraryStream,createLibraryDetail,libraryPromptRoles,LIBRARY_TAG_MODES,libraryFolderOptions} from './library-state.mjs'; import {promptRoles} from './prompt-blocks.mjs'; import {createLibraryEditor,showWorkspaceToast} from './library-editor.mjs'; import {createLibraryEditorController} from './library-editor-state.mjs'; @@ -34,14 +34,6 @@ const archivedVisible=new Map(); const LIBRARY_SORTS=Object.freeze([['newest','Recently added'],['name','Name A–Z'],['oldest','Oldest first']]); // Refine › Tag match (#444 D149, D150), in LIBRARY_TAG_MODES order. const TAG_MATCH_OPTIONS=Object.freeze([['all','All of these tags'],['any','Any of these tags'],['untagged','Prompts without 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)}`];})]; -} // A card's focusable controls in order: the D148 selection checkbox, then its buttons (#437). const CARD_CONTROLS='button, input[type="checkbox"]'; const CATEGORIES_FAILED='Categories could not be loaded. Open the Prompt library again to retry.';