* feat(saved-views): keep a collection in a saved view Images saved views gain a collection key (#331 D140): empty or a collection id of 1 to 9 digits. Writes refuse favorites together with a collection; older rows read an empty collection, and a stored row with both opens the collection, as the listing does for a URL with both. Part of #331 * feat(collections): store collections in the library database * feat(collections): add, remove and rename collections and keep members through duplicate cleanup * feat(collections): add collection routes and the Images collection filter * feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * fix(collections): address review findings * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * feat(collections): browse a collection from the sidebar The sidebar gains the concept's COLLECTIONS group (D136): one row per collection with its folder icon, name and listed-member count, without the PROPOSED badge; the label shows once a collection exists. A row opens #/collection/<id>, the Images page filtered to that collection (D137): the collection's name as h1, Collection as breadcrumb and title, Edit to rename or delete it through the saved views' Edit dialog with collection nouns, and a note counting members the sources do not list now. An unknown or deleted collection shows a page that says so with a link to Images. A saved view on a deleted collection lists nothing and says so (MC6, D140). The list is read again after a restore and the counts 300 ms after a change. Part of #331 * test(collections): compare the COLLECTIONS group with the concept The concept hides COLLECTIONS at 950 px and below, which would leave a phone no way to open a collection; production shows the rows there in the wrapped layout of every other sidebar nav (D136). The shell comparison now compares the group instead of filtering it out: the concept fixture seeds Landscapes (12) and Experiments (8), the rows compare by name order, and at phone width the nav compares against the concept's own sidebar-nav layout. The collection rows are checked at 1600x1100 and 390x844 in both themes. Part of #331 * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(browser): install the slideshow clock before wrapping setTimeout The slideshow fixture wrapped window.setTimeout before page.clock.install(), so the viewer's timer ran on real time and clock.runFor() did not drive it; a late WebKit timer failed 'slideshow exposes exact state labels' on CI. * fix(collections): address review findings - A list read superseded by a newer one (the 300 ms count read, a restore) no longer shows "could not be loaded": the collection and saved-view pages wait for the list to settle. - A listing that finds the open collection gone shows the deleted page once the list no longer holds it; when the list read fails or still holds it, the page keeps the listing's error and Retry instead of staying blank, and each failed read is reported again. - The missing-members note follows every list read and goes away at zero. - A rename that settles after its Edit dialog closed leaves any dialog opened since alone. - The deleted-collection, list-failed and saved-view failure pages focus their title after a route change, as every page does. Part of #331 * test(coverage): keep main's floors for files this PR does not touch coverage:update raised nine floors of untouched files from one run; metadata-autocomplete.js branches then measured 84.84 against 85. * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged. * test(duplicates): a removed copy with a collection and a category moves both (#331, #332)
146 lines
4.8 KiB
Python
146 lines
4.8 KiB
Python
"""Owned, deterministic Library data for the native recipe editor workflows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from aiohttp import web
|
|
from PIL import Image
|
|
from tests.browser.folder_trash import replace_folder
|
|
|
|
if TYPE_CHECKING:
|
|
from py.api import PromptManagerAPI
|
|
|
|
|
|
EDITOR_POSITIVE = "Editor portrait, soft window light"
|
|
FIRST_NEGATIVE = "blurry, watermark"
|
|
SELECTED_NEGATIVE = "low contrast, muddy shadows"
|
|
ARCHIVED_NEGATIVE = "oversaturated, harsh flash"
|
|
STANDALONE = "Standalone sketch without saved negatives"
|
|
EDITOR_METADATA = {
|
|
"title": "Editor portrait",
|
|
"category": "Portraits",
|
|
"tags": ["studio", "window light"],
|
|
"rating": 4,
|
|
"notes": "Keep the soft light.",
|
|
}
|
|
_TABLES = (
|
|
"image_collection_items",
|
|
"image_collections",
|
|
"saved_views",
|
|
"image_favorites",
|
|
"media_review_decisions",
|
|
"media_review_items",
|
|
"media_image_tags",
|
|
"media_annotation_bytes",
|
|
"media_annotation_files",
|
|
"image_categories",
|
|
"image_prompt_pairs",
|
|
"prompt_pair_tags",
|
|
"prompt_pairs",
|
|
"generated_images",
|
|
"prompt_tags",
|
|
"tags",
|
|
"prompt_library_state",
|
|
"prompts",
|
|
)
|
|
|
|
|
|
def seed_library_editor(api: PromptManagerAPI, root: Path) -> dict:
|
|
"""Replace this server's disposable rows with one fixed editor Library."""
|
|
db = api.db
|
|
with db.model.get_connection() as conn:
|
|
for table in _TABLES:
|
|
conn.execute(f"DELETE FROM {table}")
|
|
for name in ("output", "input"):
|
|
replace_folder(root / name)
|
|
epoch = db.get_execution_epoch()
|
|
receipt = db.create_library_recipe(
|
|
EDITOR_POSITIVE,
|
|
FIRST_NEGATIVE,
|
|
expected_epoch=epoch,
|
|
parent_metadata=dict(EDITOR_METADATA),
|
|
)
|
|
positive_id = receipt["positive"]["id"]
|
|
first_pair_id = receipt["pair"]["id"]
|
|
pairs = {}
|
|
for name, text in (
|
|
("pair_id", SELECTED_NEGATIVE),
|
|
("empty_pair_id", ""),
|
|
("archived_pair_id", ARCHIVED_NEGATIVE),
|
|
):
|
|
receipt = db.add_library_negative(
|
|
positive_id,
|
|
text,
|
|
expected_epoch=epoch,
|
|
if_match=receipt["positive"]["edit_token"],
|
|
)
|
|
pairs[name] = receipt["pair"]["id"]
|
|
db.archive_prompt_pair(pairs["archived_pair_id"], True)
|
|
image_path = root / "output" / "editor" / "portrait.png"
|
|
image_path.parent.mkdir()
|
|
with Image.new("RGB", (64, 64), (40, 90, 120)) as image:
|
|
image.save(image_path)
|
|
image_id = db.link_image_to_prompt(positive_id, str(image_path))
|
|
db.link_image_to_pair(pairs["pair_id"], image_id, {"source": "editor fixture"})
|
|
db.record_prompt_pair_use(pairs["pair_id"], times=2)
|
|
standalone_id = db.save_prompt(STANDALONE, category="Sketches", tags=["draft"])
|
|
with db.model.get_connection() as conn:
|
|
negative_only_id = conn.execute(
|
|
"SELECT negative_prompt_id FROM prompt_pairs WHERE id=?",
|
|
(first_pair_id,),
|
|
).fetchone()[0]
|
|
api.invalidate_gallery_cache()
|
|
return {
|
|
"positive_id": positive_id,
|
|
"first_pair_id": first_pair_id,
|
|
**pairs,
|
|
"standalone_id": standalone_id,
|
|
"negative_only_id": negative_only_id,
|
|
"positive_text": EDITOR_POSITIVE,
|
|
"first_negative": FIRST_NEGATIVE,
|
|
"selected_negative": SELECTED_NEGATIVE,
|
|
"archived_negative": ARCHIVED_NEGATIVE,
|
|
"metadata": dict(EDITOR_METADATA),
|
|
}
|
|
|
|
|
|
class _LibraryEditorCommands:
|
|
"""One disposable fixture lifetime; an ordinary reset expires it."""
|
|
|
|
def __init__(self) -> None:
|
|
self.manifest: dict | None = None
|
|
|
|
def reset(self) -> None:
|
|
self.manifest = None
|
|
|
|
|
|
def register_library_editor_commands(
|
|
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
|
|
) -> _LibraryEditorCommands:
|
|
"""Register only fixed seed and snapshot commands; no paths or SQL input."""
|
|
state = _LibraryEditorCommands()
|
|
|
|
@routes.post("/__test__/library-editor/reset")
|
|
async def reset(request: web.Request) -> web.Response:
|
|
data = await request.json() if request.can_read_body else {}
|
|
if data != {} or request.query:
|
|
raise web.HTTPBadRequest()
|
|
state.manifest = await api._run_in_executor(seed_library_editor, api, root)
|
|
return web.json_response({"success": True, **state.manifest})
|
|
|
|
@routes.get("/__test__/library-editor/snapshot")
|
|
async def snapshot(request: web.Request) -> web.Response:
|
|
if state.manifest is None or request.query:
|
|
raise web.HTTPConflict()
|
|
path = root / "owned-library-editor-snapshot.db"
|
|
created = await api._run_in_executor(api.db.model.backup_database, str(path))
|
|
if not created:
|
|
raise web.HTTPInternalServerError()
|
|
return web.Response(
|
|
body=path.read_bytes(), content_type="application/octet-stream"
|
|
)
|
|
|
|
return state
|