Merge commit '917aa838cbe1d2f6f2c335885096812a28626cce' into feat/443-bulk-prompt-delete
This commit is contained in:
+6
-3
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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=>{
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -132,3 +132,28 @@ export function libraryWindow(count,columns,top,height,focused=-1) {
|
||||
if(focused>=0 && focused<count) keep.add(Math.floor(focused/columns));
|
||||
return [...keep].sort((a,b)=>a-b).flatMap(row=>Array.from({length:columns},(_,column)=>row*columns+column).filter(index=>index<count));
|
||||
}
|
||||
|
||||
const byFolderName=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?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;
|
||||
}
|
||||
|
||||
@@ -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.';
|
||||
|
||||
Reference in New Issue
Block a user