media/
originals/ nothing regenerates these
derived/ rebuildable from originals/, safe to wipe
a pure rename: nothing had been created yet — checked the real root on fofos, only
`website/` was there — so there is no directory to move and no file to lose. that
is the last moment this was free, which is why it happened now.
`user/` and `treated/` both named the wrong thing. `user` said who put the file
there, which stops distinguishing anything the moment a job writes one, and reads
like it should hold a folder per person. `treated` said what had happened to it,
which stops being informative as soon as there is more than one kind of treatment.
these two say which one you can afford to lose. that is the only property that
matters about the pair, and it is the one that decides every rule anybody writes
about them later — what a cleanup job may delete, what a backup must never skip,
what a conversion may overwrite without asking. a name that carries it is a rule
that cannot be forgotten, which is more than the comment above it can promise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
143 lines
5.9 KiB
TypeScript
143 lines
5.9 KiB
TypeScript
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { readSettings } from './settings';
|
|
|
|
/**
|
|
* The media directory under the root — where the images and the files live.
|
|
*
|
|
* ── The shape ──
|
|
*
|
|
* <root>/
|
|
* website/ the checkout
|
|
* media/
|
|
* originals/ what people upload, untouched. nothing regenerates these
|
|
* derived/ made from originals/, and safe to wipe
|
|
*
|
|
* ── BESIDE the checkout, never inside it ──
|
|
*
|
|
* 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.
|
|
* - it must never be tracked. media in git is a repository nobody can clone a year from now, and a
|
|
* `.gitignore` is a promise somebody eventually breaks with `git add -f`.
|
|
*
|
|
* 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.
|
|
*
|
|
* ── The names ARE the asymmetry, and that is the whole reason for them ──
|
|
*
|
|
* `originals/` cannot be regenerated. If it is lost, somebody has to be asked for the file again, and
|
|
* they may not have it.
|
|
*
|
|
* `derived/` can. Every file in it is reproducible 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.
|
|
*
|
|
* They were called `user/` and `treated/` for about an hour. Both named the wrong thing: `user` said
|
|
* who put the file there, which stops distinguishing anything the moment a job writes one, and read
|
|
* like it should hold a folder per person. `treated` said what had happened to it, which stops being
|
|
* informative as soon as there is more than one kind of treatment.
|
|
*
|
|
* These say which one you can afford to lose. Nothing enforces it yet — no code here deletes anything —
|
|
* but every rule written about these two later should start from that sentence, and a name that carries
|
|
* it is a rule that cannot be forgotten.
|
|
*/
|
|
|
|
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: 'originals', hint: 'Uploaded, untouched — nothing regenerates these' },
|
|
{ name: 'derived', hint: 'Made from originals — safe to wipe and rebuild' },
|
|
] 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 — `<root>/media`. */
|
|
path: string;
|
|
present: boolean;
|
|
/** 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<string> => {
|
|
const settings = await readSettings(email);
|
|
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> {
|
|
const path = await mediaPath(email);
|
|
|
|
const info = await stat(path).catch(() => null);
|
|
const blocked = info !== null && !info.isDirectory();
|
|
const present = info !== null && info.isDirectory();
|
|
|
|
/*
|
|
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 — the directory and everything the list above says goes in it.
|
|
*
|
|
* ── It REPAIRS as readily as it creates ──
|
|
*
|
|
* `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 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(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 };
|
|
}
|
|
}
|