From 1e0951edb08fd9c04aff7d5b36cab059daff45ef Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 14 Sep 2026 19:33:22 -0500 Subject: [PATCH 01/11] feat: add bulk asset download as zip and enhance asset selection UI --- docs/SERVER.md | 8 + dw/server/app.py | 38 +++++ tests/test_server_downloads.py | 69 +++++++- ui/CLAUDE.md | 12 ++ ui/src/lib/api.ts | 9 ++ ui/src/lib/pages/AssetsPage.svelte | 240 ++++++++++++++++++++++++++-- ui/src/lib/pages/AssetsPage.test.ts | 105 ++++++++++++ 7 files changed, 463 insertions(+), 18 deletions(-) diff --git a/docs/SERVER.md b/docs/SERVER.md index 346c7335..3e6d7205 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -455,6 +455,14 @@ The editor's forms come from these; they are just as usable from scripts: workspace's own before the shared one, the order `asset:` resolves in). An asset from a read-only examples library answers 403, the same as a read-only prompt or workflow; a name nothing holds answers 404 +- `POST /api/assets/archive` — `{"names": [...]}` (1-1000) bundles a + multi-file asset selection into one zip, named by each file's + library-relative path, which is the name its `asset:` reference carries. + The gallery archive's counterpart on the input side; it resolves down the + same search path a run does, so a selection spanning this workspace's + library, the shared one and an examples tree downloads as one archive, and + an unknown or out-of-library name 404s the whole request rather than + yielding a partial one - `POST /api/uploads?filename=...` — the raw bytes of one image, video or audio file (200MB ceiling, checked from `Content-Length` before a byte is read, and again on the body; extension held to the allowed image/video list), saved diff --git a/dw/server/app.py b/dw/server/app.py index 34e354e9..20b7bc14 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -3019,6 +3019,44 @@ def keep_output_as_asset( "shared": bool(request.shared), } + class AssetArchiveRequest(BaseModel): + names: list[str] = Field(min_length=1, max_length=MAX_ARCHIVE_FILES) + + @app.post("/api/assets/archive") + def archive_assets( + request: AssetArchiveRequest, ws: Workspace = Depends(selected_workspace) + ): + """Bundle a multi-file asset selection into one zip - the gallery's + bulk download, for the input side of it. + + Resolved down the same search path a run resolves 'asset:' in, so a + selection spanning the workspace's own library, the shared one and + an examples tree downloads as one archive; the library-relative name + is the entry name, which is the name the 'asset:' reference carries. + """ + # Resolved before anything is written, so a bad name in the + # selection fails the request instead of yielding a partial zip + paths = [(name, _asset_file(f"asset:{name}", ws)) for name in request.names] + + handle = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + try: + with handle: + with zipfile.ZipFile(handle, "w", zipfile.ZIP_DEFLATED) as archive: + for name, path in paths: + archive.write(path, arcname=name) + except BaseException: + os.unlink(handle.name) + raise + + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + logger.info(f"Archived {len(paths)} asset files") + return FileResponse( + handle.name, + media_type="application/zip", + filename=f"dw-assets-{stamp}.zip", + background=BackgroundTask(os.unlink, handle.name), + ) + @app.delete("/api/assets/{name:path}") def delete_asset(name: str, ws: Workspace = Depends(selected_workspace)): """Permanently remove one file from the asset library. diff --git a/tests/test_server_downloads.py b/tests/test_server_downloads.py index 940e3a4a..a32ee38a 100644 --- a/tests/test_server_downloads.py +++ b/tests/test_server_downloads.py @@ -9,7 +9,12 @@ # `server` is imported for its fixture: these tests need exactly the one # tests/test_server.py already defines (its extra Basic.json seed file is # harmless here), and a second copy would drift from it -from tests.test_server import server, success_script, valid_workflow # noqa: F401 +from tests.test_server import ( # noqa: F401 + asset_server, + server, + success_script, + valid_workflow, +) def test_download_output_sets_content_disposition_attachment(server, tmp_path): @@ -121,3 +126,65 @@ def test_archive_outputs_rejects_more_names_than_the_cap(server, tmp_path): ) assert response.status_code == 422 + + +def test_archive_assets_zips_every_requested_file(asset_server, tmp_path): + """The asset grid's bulk download, the gallery's own: a selection of + inputs comes back as one zip rather than N downloads the browser + throttles.""" + import io + import zipfile + + with asset_server(success_script) as client: + assets = tmp_path / "assets" + (assets / "iris.png").write_bytes(b"iris-bytes") + nested = assets / "cast" + nested.mkdir(exist_ok=True) + (nested / "priya.jpg").write_bytes(b"priya-bytes") + + response = client.post( + "/api/assets/archive", json={"names": ["iris.png", "cast/priya.jpg"]} + ) + + assert response.status_code == 200 + assert "attachment" in response.headers["content-disposition"] + assert ".zip" in response.headers["content-disposition"] + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + # the library-relative name is the entry name, so a folder in the + # library survives into the download and the reference a workflow + # carries still reads off the file you unpacked + assert sorted(archive.namelist()) == ["cast/priya.jpg", "iris.png"] + assert archive.read("iris.png") == b"iris-bytes" + assert archive.read("cast/priya.jpg") == b"priya-bytes" + + +def test_archive_assets_rejects_a_name_outside_the_library(asset_server, tmp_path): + with asset_server(success_script) as client: + (tmp_path / "secret.txt").write_bytes(b"not yours") + + response = client.post("/api/assets/archive", json={"names": ["../secret.txt"]}) + + assert response.status_code == 404 + + +def test_archive_assets_rejects_an_unknown_name(asset_server, tmp_path): + with asset_server(success_script) as client: + response = client.post("/api/assets/archive", json={"names": ["nope.png"]}) + + assert response.status_code == 404 + + +def test_archive_assets_rejects_an_empty_selection(asset_server, tmp_path): + with asset_server(success_script) as client: + response = client.post("/api/assets/archive", json={"names": []}) + + assert response.status_code == 422 + + +def test_archive_assets_rejects_more_names_than_the_cap(asset_server, tmp_path): + with asset_server(success_script) as client: + response = client.post( + "/api/assets/archive", json={"names": [f"f{i}.png" for i in range(1001)]} + ) + + assert response.status_code == 422 diff --git a/ui/CLAUDE.md b/ui/CLAUDE.md index b6507fb6..ded2f311 100644 --- a/ui/CLAUDE.md +++ b/ui/CLAUDE.md @@ -89,4 +89,16 @@ answers 403, so the page offers no delete for one at all. Uploads land in the workspace unless the `shared` toggle is on, which is the one thing about an upload that cannot be changed afterwards. +"The same UX" is literal, and three things were missing from it until they +were fixed: the detail popout is `position: sticky; bottom: 1rem` as the +gallery's is, so it rides the viewport rather than sitting at the end of the +document where a click above the fold scrolls it out of sight; the bulk +actions are the gallery's - a checkbox per tile (shift-click spans a range), +`Select all matching`, and a sticky bar with Download .zip and Delete, the +delete sequential with whatever failed staying ticked; and Escape clears the +selection before it closes the detail. The checkbox owns the tile's top-left +corner, so the origin badge moved to the right. Bulk download is +`POST /api/assets/archive`, the gallery archive's counterpart - the browser +cannot zip on its own. + See docs/SERVER.md. diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 640c7e5b..ed022de4 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -470,6 +470,15 @@ export const api = { assets: AssetFile[] folders: string[] }>('/api/assets'), + /** Download a multi-file asset selection as one zip - the gallery's bulk + * download, for the input side. Spans every library on the search path, + * since the grid does. */ + archiveAssets: (names: string[]) => + downloadResponse('/api/assets/archive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names }), + }), /** Permanently remove one asset. Answers 403 for one an examples tree * brought with it, which is not this server's to delete. */ deleteAsset: (name: string) => diff --git a/ui/src/lib/pages/AssetsPage.svelte b/ui/src/lib/pages/AssetsPage.svelte index 145294bb..27db4331 100644 --- a/ui/src/lib/pages/AssetsPage.svelte +++ b/ui/src/lib/pages/AssetsPage.svelte @@ -5,13 +5,14 @@ // meant guessing a name - and nothing showed which names a `common` or an // examples library was shadowing. The UX is the gallery's on purpose: // folder groups, a contact-sheet grid, a detail popout. - import { FolderOpen, Trash2, Upload, X } from '@lucide/svelte' + import { Download, FolderOpen, Trash2, Upload, X } from '@lucide/svelte' import { api } from '../api' import CopyButton from '../CopyButton.svelte' import Empty from '../Empty.svelte' import FolderGroups from '../FolderGroups.svelte' import HintBar from '../HintBar.svelte' import { confirmDialog } from '../confirm.svelte' + import { SvelteSet } from 'svelte/reactivity' import { notify } from '../toast' import type { AssetFile } from '../types' import WorkspacePicker from '../WorkspacePicker.svelte' @@ -28,6 +29,10 @@ // has nothing to choose between let origin = $state(null) let selected = $state(null) + // Names ticked in the grid, for the bulk actions + const picked = new SvelteSet() + // Anchor for a shift-click range, in the order the grid renders + let anchor = $state(null) let busy = $state(false) // Put the next upload in the shared library rather than this workspace's, // which is the one thing about an upload that cannot be changed afterwards @@ -44,6 +49,7 @@ error = '' if (selected && !result.assets.some((a) => a.name === selected?.name)) selected = null + prune() }) .catch((e) => (error = e.message)) .finally(() => (loaded = true)) @@ -68,6 +74,94 @@ const byName = $derived(new Map(visible.map((a) => [a.name, a]))) const folderByName = $derived(new Map(assets.map((a) => [a.name, a.folder]))) + const pickedCount = $derived(picked.size) + /** The selection in the order the grid shows them, which is the order + * the bulk actions act in and the order a shift-range spans. */ + const pickedNames = $derived( + visible.filter((a) => picked.has(a.name)).map((a) => a.name), + ) + + /** Forget names no longer in the library, so an asset deleted elsewhere + * cannot linger in the selection and fail every later bulk action. */ + function prune() { + const present = new Set(assets.map((a) => a.name)) + for (const name of [...picked]) if (!present.has(name)) picked.delete(name) + } + + function togglePick(name: string, shift: boolean) { + if (shift && anchor !== null) { + const order = visible.map((a) => a.name) + const from = order.indexOf(anchor) + const to = order.indexOf(name) + if (from !== -1 && to !== -1) { + const [low, high] = from < to ? [from, to] : [to, from] + // A range always selects: extending a selection is what shift is + // for, and toggling each cell would make the result depend on + // whatever the range happened to contain + for (const each of order.slice(low, high + 1)) picked.add(each) + anchor = name + return + } + } + if (picked.has(name)) picked.delete(name) + else picked.add(name) + anchor = name + } + + function selectAllVisible() { + for (const asset of visible) picked.add(asset.name) + } + + function clearPicked() { + picked.clear() + anchor = null + } + + async function downloadPicked() { + busy = true + try { + await api.archiveAssets(pickedNames) + } catch (e) { + notify.error(e instanceof Error ? e.message : String(e)) + } finally { + busy = false + } + } + + async function removePicked() { + const names = pickedNames + if ( + !(await confirmDialog( + `Delete ${names.length} asset${names.length === 1 ? '' : 's'}? Any ` + + `workflow still carrying one of those references stops loading.`, + { confirmLabel: 'Delete' }, + )) + ) + return + busy = true + // Sequential rather than parallel, as the gallery's is: a selection of + // hundreds should not open hundreds of sockets + const failed: string[] = [] + for (const name of names) { + try { + await api.deleteAsset(name) + } catch { + failed.push(name) + } + } + // Whatever could not be deleted stays selected - a read-only examples + // asset answers 403 - so a retry needs no re-ticking + picked.clear() + for (const name of failed) picked.add(name) + anchor = null + if (failed.length) + notify.error( + `Could not delete ${failed.length} of ${names.length} assets: ${failed.join(', ')}`, + ) + busy = false + await load() + } + // A pick of a library that no longer has anything in it means every // library, not an empty grid pinned to a vanished value $effect(() => { @@ -122,6 +216,9 @@ try { await api.deleteAsset(asset.name) notify.success(`Deleted ${asset.reference}`) + // It may also be ticked in the grid; leaving it there would make the + // next bulk action fail on an asset that is gone + picked.delete(asset.name) if (selected?.name === asset.name) selected = null await load() } catch (e) { @@ -134,7 +231,15 @@ { - if (e.key === 'Escape' && selected) selected = null + // Escape closes the thing on top. A dialog - the delete confirm, the + // token popover, the keyboard help - answers it itself, so the detail + // panel underneath must not go with it + if (e.key !== 'Escape') return + if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return + // The selection is the more recent, more surprising state to be stuck + // in, so it clears first and the detail panel on a second press + if (picked.size) clearPicked() + else selected = null }} /> @@ -190,6 +295,29 @@ {/if} +{#if pickedCount} +
+ {pickedCount} selected + + + + +
+{/if} + +
+ +
+ a.name)} groupOf={(name) => folderByName.get(name) ?? ''} @@ -199,7 +327,18 @@ > {#snippet card(name)} {@const asset = byName.get(name)!} -
+
+ + togglePick(name, e.shiftKey)} + /> - - -
-{/if} - -
- -
+ a.name)} @@ -354,17 +277,17 @@ visible from every workspace and cannot be changed afterwards" > {#snippet card(name)} {@const asset = byName.get(name)!} -
+
togglePick(name, e.shiftKey)} + onclick={(e) => picks.toggle(name, e.shiftKey)} />