Skip to the content

Capsid menu

app/lib/media/actions.server.ts

295 lines and 23 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of actions.server.ts
FunctionLinesComplexityCoveredCRAPMutation
bulkTagMedia30 to 638No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
read48 to 512No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
write52 to 521No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
setMediaTagsFromForm65 to 877No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
bulkTrashMedia89 to 1127No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
trashMedia114 to 1223No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
restoreMedia124 to 1323No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
emptyMediaTrash135 to 1759No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
setMediaAlt177 to 1834No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
mediaRowTotal186 to 1891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:188>188 to 1881No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
rebuildMedia192 to 2224No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:195>195 to 1951No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:197>197 to 1971No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:202>202 to 2021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
mediaUsage225 to 2363No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
mediaDeleteVerdict249 to 2656No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
deleteMedia268 to 3127No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:291>291 to 2911No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
describeCitations314 to 3264No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:321>321 to 3242No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:322>322 to 3221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:323>323 to 3231No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/db/index.ts, app/db/schema.ts, app/lib/admin/bulk-tag.ts, app/lib/error-message.mjs, app/lib/media/classify.mjs, app/lib/media/core.server.ts, app/lib/media/rebuild.server.ts, app/lib/media/resolvers.server.ts, app/lib/media/tags.mjs. Imported by app/lib/carrel/media-adapter.server.ts, app/routes/admin.media._index.tsx.

Source

import {
  claimMediaKeyForDelete,
  mediaCounts,
  mediaRecord,
  mediaRefsFor,
  mediaRoleCounts,
  restoreMediaRecord,
  setMediaTags,
  trashMediaRecord,
  upsertMediaRecord,
} from "~/db";
import type { MediaRef } from "~/db/schema";
import { applyBulkTag } from "~/lib/admin/bulk-tag";
import { errorMessage } from "~/lib/error-message.mjs";
import { storageOf } from "./classify.mjs";
import { deleteMediaObject, isManagedKey } from "./core.server";
import { rebuildMediaIndex } from "./rebuild.server";
import { resolveCitations, type MediaCitation } from "./resolvers.server";
import { normaliseTags, parseTags } from "./tags.mjs";

/*
 * One handler per intent of the media library's action. The route keeps the dispatch and, for the
 * three destructive intents, the typed confirmation: check:destructive reads both off the route's
 * own action, so a handler here runs only once the route has let it.
 */

/** `refused` marks a result that changed nothing or failed, which the page announces as an alert. */
type Message = { message: string; refused?: boolean };

export async function bulkTagMedia(
  env: Env,
  form: FormData,
  intent: "bulk-add-tag" | "bulk-remove-tag",
): Promise<Message> {
  const keys = form.getAll("key").map(String).filter(Boolean);
  if (keys.length === 0) return { message: "Nothing selected.", refused: true };

  const wanted = normaliseTags(String(form.get("tag") ?? ""))[0];
  if (!wanted) return { message: "Enter a tag first.", refused: true };
  const adding = intent === "bulk-add-tag";

  // Rows already in the target state are skipped, so updated_at is not touched for nothing.
  const { done, skipped, failed } = await applyBulkTag({
    ids: keys,
    wanted,
    adding,
    missing: "no row",
    read: async (key) => {
      const row = await mediaRecord(env, key);
      return row ? { item: row, tags: parseTags(row.tags) } : null;
    },
    write: (key, _row, tags) => setMediaTags(env, key, tags),
  });

  return {
    message:
      `${adding ? "Tagged" : "Untagged"} ${done} of ${keys.length}` +
      (skipped > 0 ? `, ${skipped} already ${adding ? "tagged" : "untagged"}` : "") +
      "." +
      (failed.length > 0 ? ` Failed on ${failed.join("; ")}` : ""),
    refused: failed.length > 0,
  };
}

export async function setMediaTagsFromForm(env: Env, form: FormData): Promise<Message> {
  const key = String(form.get("key") ?? "");
  const tags = String(form.get("tags") ?? "").trim();

  /* An empty field does not clear; clearing needs `clear`, so an accident cannot wipe every tag. */
  const clearing = form.get("clear") !== null;
  if (!tags && !clearing) {
    return {
      message:
        `Nothing changed for ${key}. The tag field was empty, and an empty ` +
        `field does not clear tags. Remove them one at a time, or press ` +
        `Clear all.`,
      refused: true,
    };
  }

  const saved = await setMediaTags(env, key, clearing ? "" : tags);
  return {
    message: saved.length
      ? `Tags saved for ${key}: ${saved.join(", ")}.`
      : `Tags cleared for ${key}.`,
  };
}

export async function bulkTrashMedia(env: Env, form: FormData): Promise<Message> {
  const keys = form.getAll("key").map(String).filter(Boolean);
  if (keys.length === 0) return { message: "Nothing selected.", refused: true };
  let moved = 0;
  let already = 0;
  const failed: string[] = [];
  for (const key of keys) {
    try {
      const result = await trashMediaRecord(env, key);
      if (result.moved) moved += 1;
      else already += 1;
    } catch (error) {
      failed.push(`${key}: ${errorMessage(error)}`);
    }
  }
  return {
    message:
      `Moved ${moved} of ${keys.length} to the trash` +
      (already > 0 ? `, ${already} already there` : "") +
      ". Every address still works and no published page changes." +
      (failed.length > 0 ? ` Failed on ${failed.join("; ")}` : ""),
    refused: failed.length > 0,
  };
}

export async function trashMedia(env: Env, form: FormData): Promise<Message> {
  const key = String(form.get("key") ?? "");
  const { moved } = await trashMediaRecord(env, key);
  return {
    message: moved
      ? `${key} moved to the trash. Its address still works and any page using it is unchanged; this hides it from the library.`
      : `${key} was already in the trash.`,
  };
}

export async function restoreMedia(env: Env, form: FormData): Promise<Message> {
  const key = String(form.get("key") ?? "");
  const { restored } = await restoreMediaRecord(env, key);
  return {
    message: restored
      ? `${key} restored to the library.`
      : `${key} was not in the trash.`,
  };
}

/** Empties the trash the route has confirmed, keeping every key a post cites. */
export async function emptyMediaTrash(env: Env, keys: string[]): Promise<Message> {
  /* The same scan as the single delete: media_refs alone misses a citation the pipeline has not recorded. */
  const resolution = await resolveCitations(env, keys.filter(isManagedKey));
  if (!resolution.complete) {
    return {
      message:
        `Nothing was deleted: the reference scan failed (${resolution.failed.join(", ")}), ` +
        `so it cannot be confirmed that nothing cites these files.`,
      refused: true,
    };
  }
  const deleted: string[] = [];
  const refused: string[] = [];
  for (const key of keys) {
    // Static rows have no object to remove, so emptying leaves them binned.
    if (!isManagedKey(key)) {
      refused.push(`${key} (static, nothing to delete)`);
      continue;
    }
    if ((resolution.citations.get(key) ?? []).length > 0) {
      refused.push(`${key} (a post cites it)`);
      continue;
    }
    const claimed = await claimMediaKeyForDelete(env, key);
    if (!claimed) {
      refused.push(`${key} (something cites it)`);
      continue;
    }
    try {
      await deleteMediaObject(env, key);
      deleted.push(key);
    } catch (error) {
      refused.push(`${key} (${errorMessage(error)})`);
    }
  }
  return {
    message:
      `Emptied ${deleted.length} of ${keys.length}.` +
      (refused.length ? ` Kept: ${refused.join("; ")}.` : ""),
  };
}

export async function setMediaAlt(env: Env, form: FormData): Promise<Message> {
  const key = String(form.get("key") ?? "");
  if (!isManagedKey(key)) return { message: "Not a managed media key.", refused: true };
  // Saving alt rewrites no post: alt is contextual, so only future insertions pick it up.
  await upsertMediaRecord(env, { key, alt: String(form.get("alt") ?? "") });
  return { message: `Alt text saved for ${key}. Existing posts are unchanged.` };
}

/** The row count the rebuild confirmation shows. */
export async function mediaRowTotal(env: Env) {
  const before = await mediaCounts(env);
  return before.reduce((sum, row) => sum + Number(row.n), 0);
}

/** Runs the rebuild the route has confirmed and reports what the index holds after it. */
export async function rebuildMedia(env: Env): Promise<Message> {
  const report = await rebuildMediaIndex(env);
  const after = await mediaCounts(env);
  const total = after.reduce((sum, row) => sum + Number(row.n), 0);
  const breakdown = after
    .map((row) => `${row.n} ${row.storage}/${row.kind}`)
    .sort()
    .join(", ");
  const roles = await mediaRoleCounts(env);
  const roleBreakdown = roles
    .map((row) => `${row.n} ${row.role}`)
    .sort()
    .join(", ");

  const parts = [
    `Rebuilt from ${report.scannedObjects} R2 object(s) and ${report.scannedFiles} static ` +
      `file(s). The index now holds ${total} row(s): ${breakdown}`,
    `By role: ${roleBreakdown}`,
  ];
  if (report.removed > 0) {
    parts.push(`${report.removed} row(s) removed for sources that no longer exist`);
  }
  if (report.failures.length > 0) {
    parts.push(
      `${report.failures.length} could not be derived: ${report.failures.slice(0, 3).join("; ")}` +
        (report.failures.length > 3 ? ` and ${report.failures.length - 3} more` : ""),
    );
  }
  parts.push("Alt text, captions and focal points were preserved");
  return { message: `${parts.join(". ")}.` };
}

/** Every place one key is used, on a fresh read: the resolver scan and the pipeline's recorded refs. */
export async function mediaUsage(env: Env, key: string) {
  const [resolution, refs] = await Promise.all([
    resolveCitations(env, [key]),
    mediaRefsFor(env, [key]),
  ]);
  return {
    complete: resolution.complete,
    failed: resolution.failed,
    citations: resolution.citations.get(key) ?? [],
    refs: refs.get(key) ?? [],
  };
}

/**
 * Whether one key may be deleted, as a verdict rather than prose, so the admin action and Carrel's
 * media adapter refuse on the same checks. `clear` still has to win the atomic claim.
 */
export type MediaDeleteVerdict =
  | { kind: "static" }
  | { kind: "unmanaged" }
  | { kind: "scan-failed"; failed: string[] }
  | { kind: "cited"; citations: MediaCitation[]; refs: MediaRef[] }
  | { kind: "clear" };

export async function mediaDeleteVerdict(env: Env, key: string): Promise<MediaDeleteVerdict> {
  // Refused in the action, not by hiding a button: a deleted row would return on the next rebuild.
  if (storageOf(key) === "static") return { kind: "static" };
  if (!isManagedKey(key)) return { kind: "unmanaged" };

  // Server side on a fresh read: a post may have cited the object since the page rendered.
  const usage = await mediaUsage(env, key);

  // Fail closed: a failed scan is not "nothing cites it".
  if (!usage.complete) return { kind: "scan-failed", failed: usage.failed };

  // The refcount: content-addressed keys let posts share a blob, so any citation blocks the delete.
  if (usage.refs.length > 0 || usage.citations.length > 0) {
    return { kind: "cited", citations: usage.citations, refs: usage.refs };
  }
  return { kind: "clear" };
}

/** Deletes one object the route has confirmed, refusing a static key or anything cited. */
export async function deleteMedia(env: Env, key: string): Promise<Message> {
  const verdict = await mediaDeleteVerdict(env, key);

  if (verdict.kind === "static") {
    return {
      message:
        `Delete refused: ${key} is a static asset, served from the repo rather than R2. ` +
        `Remove it with a commit that deletes the file, then rebuild the index.`,
      refused: true,
    };
  }

  if (verdict.kind === "unmanaged") return { message: "Not a managed media key.", refused: true };

  if (verdict.kind === "scan-failed") {
    return {
      message: `Delete refused: the reference scan failed (${verdict.failed.join(", ")}), so it cannot be confirmed that nothing cites this object.`,
      refused: true,
    };
  }

  if (verdict.kind === "cited") {
    const rows = verdict.refs;
    const sources = new Set(rows.map((row) => `${row.sourceType}:${row.sourceId}`));
    const refNote =
      rows.length > 0
        ? ` The pipeline recorded ${rows.length} reference(s) across ${sources.size} item(s).`
        : "";
    return {
      message: `Delete refused. ${describeCitations(verdict.citations)}${refNote}`,
      refused: true,
    };
  }

  /* One statement claims the row only if nothing cites it now. Row first: an orphan object backfills. */
  if (!(await claimMediaKeyForDelete(env, key))) {
    return {
      message: `Delete refused: ${key} was cited or removed while this delete was being checked. Reload and try again.`,
      refused: true,
    };
  }

  await deleteMediaObject(env, key);
  return { message: `Deleted ${key} and its row. Nothing cited it.` };
}

function describeCitations(citations: MediaCitation[]) {
  const byItem = new Map<string, MediaCitation[]>();
  for (const citation of citations) {
    const list = byItem.get(citation.id);
    if (list) list.push(citation);
    else byItem.set(citation.id, [citation]);
  }
  const parts = [...byItem.entries()].map(([id, list]) => {
    const forms = [...new Set(list.map((c) => c.form))].join(", ");
    return `${list[0]?.title ?? ""} (${id}: ${forms}, ${list.map((c) => c.detail).join("; ")})`;
  });
  return `Still cited by ${parts.length} post${parts.length === 1 ? "" : "s"}: ${parts.join(" and ")}.`;
}