diff --git a/api/media.ts b/api/media.ts index 0d42be0..0b67b19 100644 --- a/api/media.ts +++ b/api/media.ts @@ -3,12 +3,19 @@ import { join } from 'node:path'; import { readSettings } from './settings'; /** - * The media directory under the root — where the images and files live. + * The media directory under the root — where the images and the files live. + * + * ── The shape ── + * + * / + * website/ the checkout + * media/ + * user/ what people upload, untouched + * treated/ what comes back out of conversion * * ── BESIDE the checkout, never inside it ── * - * `/media`, a sibling of `/website`. That is the whole point of it being here rather than - * a folder in the repository: + * That is the point of it being here rather than a folder in the repository: * * - it must survive the checkout. deleting `website` and cloning it again is a normal thing to do * when something is wrong with it, and it must never be a thing that destroys the client's photos. @@ -18,24 +25,48 @@ import { readSettings } from './settings'; * The two live under one root so a backup of the root is the whole site — the code and the things it * serves — which is the property that makes the root worth being a setting at all. * - * ── Nothing about its INSIDES is decided here ── + * ── The asymmetry between the two halves ── * - * This creates the directory and reports whether it is there. What goes in it, how it is laid out, and - * who writes to it are not settled, and inventing a folder tree now would be deciding that here — in - * the file least able to change its mind later, because directories on disk are not a migration you - * can run. + * `user/` is ORIGINAL. Nothing regenerates it; if it is lost, somebody has to be asked for the file + * again, and they may not have it. + * + * `treated/` is DERIVED. Every file in it can be made again from the one it came from, so it is the + * half that is safe to delete, safe to rebuild, and safe for a job to overwrite without asking. + * + * Nothing here enforces that yet — no code deletes either — but it is the fact that should decide + * every rule written about them later, so it is recorded before the first one is. */ const DIR_NAME = 'media'; +/** + * The directories `create` makes, and the ones `read` checks for. + * + * ONE list, so provisioning and reporting cannot disagree. Two lists is how a status comes to say + * "ready" about a structure that is missing a folder the creator stopped making. + */ +export const MEDIA_CHILDREN = [ + { name: 'user', hint: 'What people upload, untouched' }, + { name: 'treated', hint: 'What comes back out of conversion' }, +] as const; + +export type MediaChildStatus = { + name: string; + hint: string; + present: boolean; + /** How many things are directly inside. Null when it is not there, or unreadable. */ + entries: number | null; +}; + export type MediaStatus = { /** Absolute — `/media`. */ path: string; present: boolean; - /** How many things are directly inside. Null when it is not there. */ - entries: number | null; - /** It exists but is not a directory — a file in the way, which `mkdir` would fail on. */ + /** It exists but is not a directory — something in the way that `mkdir` would fail on. */ blocked: boolean; + children: MediaChildStatus[]; + /** Everything in the list above is there. What the button is asking about. */ + complete: boolean; }; export const mediaPath = async (email: string): Promise => { @@ -43,33 +74,61 @@ export const mediaPath = async (email: string): Promise => { return join(settings.rootPath, DIR_NAME); }; +const describe = async (path: string): Promise<{ present: boolean; entries: number | null }> => { + const info = await stat(path).catch(() => null); + if (!info?.isDirectory()) return { present: false, entries: null }; + const entries = await readdir(path).catch(() => null); + return { present: true, entries: entries?.length ?? null }; +}; + export async function readMediaStatus(email: string): Promise { const path = await mediaPath(email); const info = await stat(path).catch(() => null); - if (!info) return { path, present: false, entries: null, blocked: false }; - if (!info.isDirectory()) return { path, present: true, entries: null, blocked: true }; + const blocked = info !== null && !info.isDirectory(); + const present = info !== null && info.isDirectory(); - const entries = await readdir(path).catch(() => null); - return { path, present: true, entries: entries?.length ?? null, blocked: false }; + /* + The children are checked even when the parent is missing, so their answer is always about the same + thing. Reporting them as absent because the parent is absent would be true but useless — and the + moment somebody makes `media` by hand, a status that had skipped the check would be stale rather + than wrong, which is harder to notice. + */ + const children = await Promise.all( + MEDIA_CHILDREN.map(async (child) => ({ + name: child.name, + hint: child.hint, + ...(await describe(join(path, child.name))), + })), + ); + + return { path, present, blocked, children, complete: present && children.every((child) => child.present) }; } /** - * Make it. + * Make it — the directory and everything the list above says goes in it. * - * `recursive`, so it also creates the root when the root is still only a default — saving a root does - * not create anything, and provisioning into a path that does not exist yet is the ordinary first run - * rather than an error worth reporting. + * ── It REPAIRS as readily as it creates ── * - * Idempotent by the same flag: pressing it on a directory that already exists changes nothing and - * fails at nothing, which is what makes it safe to offer next to a status that might be stale. + * `mkdir -p` on every path in the list, every time. So the same button fixes a `media/` somebody made + * by hand with nothing in it, and a structure that lost a folder — and pressing it on a complete one + * changes nothing and fails at nothing. + * + * That is what makes it safe to offer beside a status that might be a few seconds stale. The version + * of this that refuses when the parent exists is the version somebody has to fix with a terminal. + * + * `recursive` also covers the root itself still being only a default: saving a root creates nothing, + * so provisioning into a path that does not exist yet is the ordinary first run. */ export async function createMediaDir(email: string): Promise<{ ok: boolean; error?: string }> { - const status = await readMediaStatus(email); - if (status.blocked) return { ok: false, error: `${status.path} exists and is not a directory.` }; + const path = await mediaPath(email); + + const info = await stat(path).catch(() => null); + if (info && !info.isDirectory()) return { ok: false, error: `${path} exists and is not a directory.` }; try { - await mkdir(status.path, { recursive: true }); + await mkdir(path, { recursive: true }); + for (const child of MEDIA_CHILDREN) await mkdir(join(path, child.name), { recursive: true }); return { ok: true }; } catch (error) { return { ok: false, error: (error as Error).message }; diff --git a/manifest.ts b/manifest.ts index 80e950f..a0fbf78 100644 --- a/manifest.ts +++ b/manifest.ts @@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest'; */ export const manifest: PluginManifest = { publisher: 'officerdev', - version: '2026.08.29.14', + version: '2026.08.29.15', platform: '>=1.0.0', label: 'Fofos', diff --git a/web/MediaFolder.tsx b/web/MediaFolder.tsx index 5c4c5c3..4bc65c2 100644 --- a/web/MediaFolder.tsx +++ b/web/MediaFolder.tsx @@ -13,11 +13,19 @@ import { readableError } from './errors'; * No console. Making a directory either works or gives one line about why not, and a black transcript * box for a single sentence would make a small thing look like a large one. * - * What goes INSIDE is not decided yet — see `api/media.ts`. This provisions the directory and nothing - * else on purpose. + * ── The children are listed, and the button repairs ── + * + * `user/` and `treated/` are shown whether or not they are there, so a structure that is missing one + * reads as INCOMPLETE rather than as fine. The button follows: "Create" when there is nothing, "Create + * missing" when part of it exists. It is `mkdir -p` on every path either way, so pressing it can only + * add and can never fail on something already correct. */ -type Status = { path: string; present: boolean; entries: number | null; blocked: boolean }; +type Child = { name: string; hint: string; present: boolean; entries: number | null }; +type Status = { path: string; present: boolean; blocked: boolean; children: Child[]; complete: boolean }; + +const count = (entries: number | null) => + entries === null ? '' : entries === 0 ? 'empty' : `${entries} item${entries === 1 ? '' : 's'}`; export const MediaFolder = ({ rootPath }: { rootPath: string }) => { const client = useClient(); @@ -63,8 +71,10 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => { if (!status) return null; - const Icon = status.blocked ? CircleAlert : status.present ? CircleCheck : CircleDashed; - const tone = status.blocked ? 'text-destructive' : status.present ? 'text-success' : 'text-muted-foreground'; + // Three states and not two: complete, partly there, and absent. A `media/` with one folder missing + // looked identical to a finished one under `present`, which is the state most worth seeing. + const Icon = status.blocked ? CircleAlert : status.complete ? CircleCheck : CircleDashed; + const tone = status.blocked ? 'text-destructive' : status.complete ? 'text-success' : 'text-muted-foreground'; return (
@@ -75,23 +85,36 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => { {status.blocked ? (

Something is already here, and it is not a directory.

+ ) : status.complete ? ( +

Ready.

) : status.present ? ( -

- {status.entries === null - ? 'Ready.' - : status.entries === 0 - ? 'Ready — empty.' - : `Ready — ${status.entries} item${status.entries === 1 ? '' : 's'}.`} -

+

Partly there.

) : (

Not created yet.

)} + + {/* The structure, always — the folders that are missing are the point of showing it. Named + with what they hold, because "user" and "treated" say who and what happened rather than + what goes in them. */} +
    + {status.children.map((child) => ( +
  • + + {child.name}/ + + + {child.hint} + {child.present && count(child.entries) ? ` · ${count(child.entries)}` : ''} + +
  • + ))} +
- {!status.present && ( + {!status.blocked && !status.complete && ( )}