media/ gets its shape: user/ and treated/

<root>/
    website/          the checkout
    media/
      user/           what people upload, untouched
      treated/        what comes back out of conversion

ONE list drives both provisioning and reporting. two lists is how a status comes to
say "ready" about a structure missing a folder the creator quietly stopped making.

THE BUTTON REPAIRS AS READILY AS IT CREATES. `mkdir -p` on every path, every time,
so the same press 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 a few
seconds stale; the version that refuses when the parent exists is the version
somebody has to finish in a terminal.

three states, not two. `media/` with one child missing looked identical to a
finished one, and that is the state most worth seeing — so the row says complete,
partly there, or absent, and lists both children whether or not they exist. the
missing ones are the reason to show the list at all.

RECORDED BEFORE ANY RULE DEPENDS ON IT: the two halves are not symmetric. `user/`
is original — nothing regenerates it, and losing it means asking somebody for a
file they may no longer have. `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 enforces that yet;
it is the fact that should decide every rule written about them later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:11:16 +01:00
co-authored by Claude Opus 5
parent 003613872d
commit 86e18fc7bf
3 changed files with 121 additions and 39 deletions
+83 -24
View File
@@ -3,12 +3,19 @@ import { join } from 'node:path';
import { readSettings } from './settings'; 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 ──
*
* <root>/
* website/ the checkout
* media/
* user/ what people upload, untouched
* treated/ what comes back out of conversion
* *
* ── BESIDE the checkout, never inside it ── * ── BESIDE the checkout, never inside it ──
* *
* `<root>/media`, a sibling of `<root>/website`. That is the whole point of it being here rather than * That is the point of it being here rather than a folder in the repository:
* a folder in the repository:
* *
* - it must survive the checkout. deleting `website` and cloning it again is a normal thing to do * - 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. * 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 * 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. * 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 * `user/` is ORIGINAL. Nothing regenerates it; if it is lost, somebody has to be asked for the file
* who writes to it are not settled, and inventing a folder tree now would be deciding that here — in * again, and they may not have it.
* the file least able to change its mind later, because directories on disk are not a migration you *
* can run. * `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'; 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 = { export type MediaStatus = {
/** Absolute — `<root>/media`. */ /** Absolute — `<root>/media`. */
path: string; path: string;
present: boolean; present: boolean;
/** How many things are directly inside. Null when it is not there. */ /** It exists but is not a directory — something in the way that `mkdir` would fail on. */
entries: number | null;
/** It exists but is not a directory — a file in the way, which `mkdir` would fail on. */
blocked: boolean; 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<string> => { export const mediaPath = async (email: string): Promise<string> => {
@@ -43,33 +74,61 @@ export const mediaPath = async (email: string): Promise<string> => {
return join(settings.rootPath, DIR_NAME); 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<MediaStatus> { export async function readMediaStatus(email: string): Promise<MediaStatus> {
const path = await mediaPath(email); const path = await mediaPath(email);
const info = await stat(path).catch(() => null); const info = await stat(path).catch(() => null);
if (!info) return { path, present: false, entries: null, blocked: false }; const blocked = info !== null && !info.isDirectory();
if (!info.isDirectory()) return { path, present: true, entries: null, blocked: true }; 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 * ── It REPAIRS as readily as it creates ──
* not create anything, and provisioning into a path that does not exist yet is the ordinary first run
* rather than an error worth reporting.
* *
* Idempotent by the same flag: pressing it on a directory that already exists changes nothing and * `mkdir -p` on every path in the list, every time. So the same button fixes a `media/` somebody made
* fails at nothing, which is what makes it safe to offer next to a status that might be stale. * 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 }> { export async function createMediaDir(email: string): Promise<{ ok: boolean; error?: string }> {
const status = await readMediaStatus(email); const path = await mediaPath(email);
if (status.blocked) return { ok: false, error: `${status.path} exists and is not a directory.` };
const info = await stat(path).catch(() => null);
if (info && !info.isDirectory()) return { ok: false, error: `${path} exists and is not a directory.` };
try { 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 }; return { ok: true };
} catch (error) { } catch (error) {
return { ok: false, error: (error as Error).message }; return { ok: false, error: (error as Error).message };
+1 -1
View File
@@ -35,7 +35,7 @@ import type { PluginManifest } from '@@/plugins/manifest';
*/ */
export const manifest: PluginManifest = { export const manifest: PluginManifest = {
publisher: 'officerdev', publisher: 'officerdev',
version: '2026.08.29.14', version: '2026.08.29.15',
platform: '>=1.0.0', platform: '>=1.0.0',
label: 'Fofos', label: 'Fofos',
+37 -14
View File
@@ -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 * 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. * 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 * ── The children are listed, and the button repairs ──
* else on purpose. *
* `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 }) => { export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
const client = useClient(); const client = useClient();
@@ -63,8 +71,10 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
if (!status) return null; if (!status) return null;
const Icon = status.blocked ? CircleAlert : status.present ? CircleCheck : CircleDashed; // Three states and not two: complete, partly there, and absent. A `media/` with one folder missing
const tone = status.blocked ? 'text-destructive' : status.present ? 'text-success' : 'text-muted-foreground'; // 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 ( return (
<div className="flex flex-col gap-2 border-t border-border pt-3"> <div className="flex flex-col gap-2 border-t border-border pt-3">
@@ -75,23 +85,36 @@ export const MediaFolder = ({ rootPath }: { rootPath: string }) => {
{status.blocked ? ( {status.blocked ? (
<p className="text-xs text-destructive">Something is already here, and it is not a directory.</p> <p className="text-xs text-destructive">Something is already here, and it is not a directory.</p>
) : status.complete ? (
<p className="text-xs text-muted-foreground">Ready.</p>
) : status.present ? ( ) : status.present ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">Partly there.</p>
{status.entries === null
? 'Ready.'
: status.entries === 0
? 'Ready — empty.'
: `Ready — ${status.entries} item${status.entries === 1 ? '' : 's'}.`}
</p>
) : ( ) : (
<p className="text-xs text-muted-foreground">Not created yet.</p> <p className="text-xs text-muted-foreground">Not created yet.</p>
)} )}
{/* 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. */}
<ul className="pt-1">
{status.children.map((child) => (
<li key={child.name} className="flex items-baseline gap-2 text-xs">
<span className={`font-mono ${child.present ? '' : 'text-muted-foreground/60 line-through'}`}>
{child.name}/
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground/70">
{child.hint}
{child.present && count(child.entries) ? ` · ${count(child.entries)}` : ''}
</span>
</li>
))}
</ul>
</div> </div>
{!status.present && ( {!status.blocked && !status.complete && (
<Button size="sm" className="shrink-0" onClick={() => void create()} disabled={busy}> <Button size="sm" className="shrink-0" onClick={() => void create()} disabled={busy}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <FolderPlus className="size-3.5" />} {busy ? <Loader2 className="size-3.5 animate-spin" /> : <FolderPlus className="size-3.5" />}
Create {status.present ? 'Create missing' : 'Create'}
</Button> </Button>
)} )}
</div> </div>