app/lib/admin/posts-actions.server.ts
228 lines and 12 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| unpurgedNote | 25 to 29 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| regeneratePosts | 31 to 47 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| askSyncConfirmCount | 50 to 53 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| syncAskIndex | 56 to 77 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:67> | 67 to 67 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| duplicatePost | 83 to 126 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| unpublishPost | 132 to 159 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| bulkDeletePosts | 162 to 185 | 10 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| bulkTagPosts | 187 to 239 | 8 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| read | 208 to 213 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| write | 214 to 222 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| resetAskBudgetAndReport | 241 to 246 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
Imports
It imports app/db/index.ts, app/lib/admin/bulk-tag.ts, app/lib/content/slug.mjs, app/lib/editor/duplicate.mjs, app/lib/editor/frontmatter.ts, app/lib/editor/github.server.ts, app/lib/editor/publish-policy.mjs, app/lib/editor/publish.server.ts, app/lib/error-message.mjs, app/lib/search/ask-guard.server.ts, app/lib/search/ask.server.ts. Imported by app/routes/admin.posts._index.tsx.
Source
import { redirect } from "react-router";
import { listAllPostsForAdmin } from "~/db";
import { applyBulkTag } from "~/lib/admin/bulk-tag";
import { postPath } from "~/lib/content/slug.mjs";
import { copySlugCandidates } from "~/lib/editor/duplicate.mjs";
import { parsePost, parseTagInput, serializePost } from "~/lib/editor/frontmatter";
import { readFile } from "~/lib/editor/github.server";
import type { Actor } from "~/lib/editor/publish-policy.mjs";
import { deletePost, regenerateAllFromRepo, savePost } from "~/lib/editor/publish.server";
import { errorMessage } from "~/lib/error-message.mjs";
import { askAvailable, pruneAskCorpus, syncAskCorpus } from "~/lib/search/ask.server";
import { readAskBudget, resetAskBudget } from "~/lib/search/ask-guard.server";
/*
* One handler per intent of the posts list's action. The route keeps the dispatch and, for its two
* 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.
*/
/* `failed` picks the live region: a failure interrupts as an alert, a result waits as a status. */
type Message = { message: string; failed?: true };
/** Appended when a write landed but its cache purge did not: the public pages are stale until expiry. */
function unpurgedNote(count: number) {
return count > 0
? ` The cache purge failed for ${count} post(s), so public pages may show the old version until their cache expires.`
: "";
}
export async function regeneratePosts(env: Env): Promise<Message> {
try {
const { synced, removed, unpurged } = await regenerateAllFromRepo(env);
return {
message:
`Re-rendered ${synced} posts from the repository` +
(removed > 0 ? ` and removed ${removed} no longer in it` : "") +
"." +
unpurgedNote(unpurged),
};
} catch (error) {
return {
message: `Regenerate failed. ${errorMessage(error)}`,
failed: true,
};
}
}
/** The post count the sync confirmation shows: what the index will hold afterwards. */
export async function askSyncConfirmCount(env: Env) {
const posts = await listAllPostsForAdmin(env);
return posts.length;
}
/** Runs the sync the route has confirmed: upload every record, then prune what this run did not upload. */
export async function syncAskIndex(env: Env): Promise<Message> {
try {
const { uploaded, keys, failed, cacheDropped } = await syncAskCorpus(env);
const removed = await pruneAskCorpus(env, keys);
return {
message:
`Uploaded ${uploaded} search records to AI Search` +
(removed.length > 0 ? `, removed ${removed.length} stale item(s)` : "") +
`, dropped ${cacheDropped} cached answer(s).` +
// Named, so a transient that outlived its retries is not read as a finished sync.
(failed.length > 0
? ` FAILED after retries: ${failed.map((f) => f.key).join(", ")}. Run the sync again.`
: ""),
...(failed.length > 0 ? { failed: true as const } : {}),
};
} catch (error) {
return {
message: `Ask sync failed. ${errorMessage(error)}`,
failed: true,
};
}
}
/*
* Not a back door to a first publication: the copy is written with `draft: true`, and
* `forceFirstPublished` removes the key rather than carrying it across.
*/
export async function duplicatePost(env: Env, form: FormData, actor: Actor) {
const slug = String(form.get("slug") ?? "");
const file = await readFile(env, postPath(slug));
if (!file) {
return {
message: `No post file exists for "${slug}", so there is nothing to copy.`,
failed: true as const,
};
}
const fields = parsePost(file.content);
/* Probed against the repository, not D1: D1 is derived, and the file is what `savePost` refuses on. */
let target: string | null = null;
const candidates = copySlugCandidates(slug);
for (const candidate of candidates) {
if (!(await readFile(env, postPath(candidate)))) {
target = candidate;
break;
}
}
if (!target) {
return {
message:
`Could not find a free slug for a copy of "${slug}": the first ` +
`${candidates.length} candidates are all taken. Delete some copies first.`,
failed: true as const,
};
}
try {
await savePost(env, {
slug: target,
raw: serializePost({ ...fields, slug: target, draft: true, firstPublished: "" }),
isNew: true,
actor,
});
return redirect(`/admin/posts/${target}/edit`);
} catch (error) {
return {
message: `Duplicate failed. ${errorMessage(error)}`,
failed: true as const,
};
}
}
/*
* No republish here: `first_published` is frontmatter, not a D1 column, so this list cannot tell
* a never-published draft from a withdrawn one.
*/
export async function unpublishPost(env: Env, form: FormData, actor: Actor): Promise<Message> {
const slug = String(form.get("slug") ?? "");
const file = await readFile(env, postPath(slug));
if (!file) return { message: `No post file exists for "${slug}".`, failed: true };
const fields = parsePost(file.content);
// The committed file decides, not the rendered row: another tab may already have withdrawn it.
if (fields.draft) return { message: `"${slug}" is already a draft. Nothing changed.` };
try {
const { purged } = await savePost(env, {
slug,
raw: serializePost({ ...fields, draft: true }),
isNew: false,
actor,
});
return {
message:
`Unpublished "${slug}". It is a draft now, so it is off the public site, ` +
`the feeds and the sitemap. Republish it from its editor.` +
unpurgedNote(purged === false ? 1 : 0),
};
} catch (error) {
return {
message: `Unpublish failed. ${errorMessage(error)}`,
failed: true,
};
}
}
/** Deletes the selection the route has confirmed, one post at a time through the per-post writer. */
export async function bulkDeletePosts(env: Env, slugs: string[], actor: Actor): Promise<Message> {
const failed: string[] = [];
let done = 0;
let unpurged = 0;
const askFailures: string[] = [];
for (const slug of slugs) {
try {
const { purged, askRemoval } = await deletePost(env, { slug, actor });
done += 1;
if (purged === false) unpurged += 1;
if (askRemoval && !askRemoval.ok) askFailures.push(`${slug}: ${askRemoval.message}`);
} catch (error) {
failed.push(`${slug}: ${errorMessage(error)}`);
}
}
return {
message:
`Deleted ${done} of ${slugs.length}.` +
(failed.length > 0 ? ` Failed on ${failed.join("; ")}` : "") +
(askFailures.length > 0 ? ` Ask removal failed on ${askFailures.join("; ")}` : "") +
unpurgedNote(unpurged),
...(failed.length > 0 || askFailures.length > 0 ? { failed: true as const } : {}),
};
}
export async function bulkTagPosts(
env: Env,
form: FormData,
slugs: string[],
intent: "bulk-add-tag" | "bulk-remove-tag",
actor: Actor,
): Promise<Message> {
const failed: string[] = [];
let unpurged = 0;
const wanted = parseTagInput(String(form.get("tag") ?? ""))[0];
if (!wanted) return { message: "Enter a tag first.", failed: true };
const adding = intent === "bulk-add-tag";
// A no-op post is skipped: writing it costs a commit and rewrites frontmatter whose key order is
// not yet canonical.
const tally = await applyBulkTag({
ids: slugs,
wanted,
adding,
missing: "no source file",
read: async (slug) => {
const file = await readFile(env, postPath(slug));
if (!file) return null;
const fields = parsePost(file.content);
return { item: fields, tags: fields.tags };
},
write: async (slug, fields, tags) => {
const { purged } = await savePost(env, {
slug,
raw: serializePost({ ...fields, tags }),
isNew: false,
actor,
});
if (purged === false) unpurged += 1;
},
});
const done = tally.done;
const skipped = tally.skipped;
failed.push(...tally.failed);
const verb = adding ? "Tagged" : "Untagged";
const why = adding ? "already tagged" : "not tagged";
return {
message:
`${verb} ${done} with "${wanted}"` +
(skipped > 0 ? `, skipped ${skipped} ${why}` : "") +
"." +
(failed.length > 0 ? ` Failed on ${failed.join("; ")}` : "") +
unpurgedNote(unpurged),
...(failed.length > 0 ? { failed: true as const } : {}),
};
}
export async function resetAskBudgetAndReport(env: Env): Promise<Message> {
if (!askAvailable(env)) return { message: "Ask is not enabled.", failed: true };
await resetAskBudget(env);
const after = await readAskBudget(env);
return { message: `Ask budget reset. ${after.count} of ${after.limit} used today.` };
}