import type { ResumeDoc, ResumeSection, SectionKind } from "@/lib/resume-schema"; /** * Moving one thing inside a resume document. * * Pure, and deliberately outside src/lib/data/ so the editor can import it into * the browser for its drag handles while `reorder_resume` imports it on the * server. What "move this above that" means has one implementation, not two. * * Everything here takes and returns a whole document and mutates nothing: the * editor holds the result in state and lets autosave write it, and the data * layer re-parses it before the write, so a bad move cannot reach the database * in a shape the schema would reject. */ /** Where a section keeps its entries. Summary sections have none. */ const ENTRY_KEY: Record = { summary: null, experience: "experience", education: "education", projects: "projects", skills: "skills", certifications: "certifications", custom: "items", }; /** * The fields of an entry this module actually looks at. Six item types share * one shape here — every one of them names itself with one of these keys and * keeps its lines under `bullets` or, for education, `details`. Reading them * through one type keeps the move in one piece instead of six branches; the * schema is still what validates the result on the way to the database. */ type AnyEntry = { id?: string; title?: string; company?: string; school?: string; name?: string; bullets?: string[]; details?: string[]; }; export function entriesOf(section: ResumeSection): AnyEntry[] { const key = ENTRY_KEY[section.kind]; return key ? (section[key] as unknown as AnyEntry[]) : []; } function withEntries(section: ResumeSection, entries: AnyEntry[]): ResumeSection { const key = ENTRY_KEY[section.kind]; return key ? ({ ...section, [key]: entries } as ResumeSection) : section; } /** * Every name an entry answers to, most specific first. * * A job goes by its title and by its company, and a person naming one says * either — "move the Stripe job up" and "move the staff engineer role up" are * the same request. Matching only the display label made the second work and * the first fail with a list that did not even mention the word they used. */ export function entryNames(entry: AnyEntry): string[] { return [entry.title, entry.company, entry.school, entry.name].filter( (name): name is string => Boolean(name), ); } /** What an entry calls itself, whichever kind it is. */ export function entryLabel(entry: AnyEntry): string { return entryNames(entry)[0] ?? ""; } /** An entry's lines, under whichever key its kind uses. */ function linesOf(entry: AnyEntry): { key: "bullets" | "details"; lines: string[] } { if (entry.details && !entry.bullets) return { key: "details", lines: entry.details }; return { key: "bullets", lines: entry.bullets ?? [] }; } /** Move one item of a list to a new index, closing the gap behind it. */ export function moveWithin(list: T[], from: number, to: number): T[] { if (from === to || from < 0 || from >= list.length) return list; const next = [...list]; const [item] = next.splice(from, 1); next.splice(Math.min(Math.max(to, 0), next.length), 0, item); return next; } type Candidate = { id?: string; /** What the error message and the result call it. */ label: string; /** Everything it answers to, the label included. */ names?: string[]; }; /** * Find one thing in a list by whatever the caller had to hand. * * An assistant that just read a document has ids; a person talking to it has * "the Stripe job" or "the third bullet". All of them resolve here, in that * order of confidence, and a miss reports what was actually available rather * than only that it failed — an assistant can retry from the error. */ function locate(candidates: Candidate[], needle: string, what: string): number { const query = needle.trim(); if (!query) throw new Error(`Name the ${what} to move.`); if (/^\d+$/.test(query)) { const index = Number(query) - 1; if (index < 0 || index >= candidates.length) { throw new Error(`There is no ${what} number ${query} — there are ${candidates.length}.`); } return index; } const lower = query.toLowerCase(); const names = (c: Candidate) => (c.names ?? [c.label]).map((name) => name.toLowerCase()); const tests = [ (c: Candidate) => c.id === query, (c: Candidate) => names(c).some((name) => name === lower), (c: Candidate) => names(c).some((name) => name.startsWith(lower)), (c: Candidate) => names(c).some((name) => name.includes(lower)), ]; let found = -1; for (const test of tests) { found = candidates.findIndex(test); if (found !== -1) break; } if (found === -1) { const options = candidates.map((c, i) => `${i + 1}. ${c.label || "(untitled)"}`).join("; "); throw new Error(`No ${what} matching "${query}". This resume has: ${options || "none"}.`); } return found; } export type ReorderInput = { /** The section, by id, heading or 1-based number. */ section: string; /** An entry inside it, by id, name or 1-based number. */ entry?: string; /** A bullet inside that entry, by text or 1-based number. */ bullet?: string; /** Where it lands, 1-based. Clamped to the list. */ position: number; }; export type ReorderResult = { doc: ResumeDoc; moved: { kind: "section" | "entry" | "bullet"; label: string; from: number; to: number }; }; /** * Move the deepest thing named: a bullet if one is named, else an entry, else * the section itself. Positions in and out are 1-based, because they are what * a person says and what the tool reports back. */ export function reorderDoc(doc: ResumeDoc, input: ReorderInput): ReorderResult { if (!Number.isFinite(input.position)) { throw new Error("Give position as a 1-based number: 1 puts it first."); } const sectionAt = locate( doc.sections.map((section) => ({ id: section.id, label: section.heading || section.kind, names: [section.heading, section.kind].filter(Boolean), })), input.section, "section", ); const section = doc.sections[sectionAt]; const clamp = (length: number) => Math.min(Math.max(input.position, 1), length) - 1; if (input.entry === undefined && input.bullet === undefined) { const to = clamp(doc.sections.length); return { doc: { ...doc, sections: moveWithin(doc.sections, sectionAt, to) }, moved: { kind: "section", label: section.heading || section.kind, from: sectionAt + 1, to: to + 1, }, }; } const entries = entriesOf(section); if (!entries.length) { throw new Error(`The "${section.heading || section.kind}" section has no entries to move.`); } // A bullet named without an entry is unambiguous only when the section has // one entry; anything else is a question, not a guess. if (input.entry === undefined && entries.length > 1) { throw new Error( `Say which entry the bullet is in: the "${section.heading || section.kind}" section has ${entries.length}.`, ); } const entryAt = input.entry === undefined ? 0 : locate( entries.map((entry) => ({ id: entry.id, label: entryLabel(entry), names: entryNames(entry), })), input.entry, "entry", ); const entry = entries[entryAt]; if (input.bullet === undefined) { const to = clamp(entries.length); return { doc: { ...doc, sections: doc.sections.map((each, at) => at === sectionAt ? withEntries(each, moveWithin(entries, entryAt, to)) : each, ), }, moved: { kind: "entry", label: entryLabel(entry), from: entryAt + 1, to: to + 1 }, }; } const { key, lines } = linesOf(entry); if (!lines.length) throw new Error(`"${entryLabel(entry)}" has no bullets to move.`); const bulletAt = locate( lines.map((line) => ({ label: line })), input.bullet, "bullet", ); const to = clamp(lines.length); const movedEntry = { ...entry, [key]: moveWithin(lines, bulletAt, to) }; return { doc: { ...doc, sections: doc.sections.map((each, at) => at === sectionAt ? withEntries( each, entries.map((one, i) => (i === entryAt ? movedEntry : one)), ) : each, ), }, moved: { kind: "bullet", label: lines[bulletAt], from: bulletAt + 1, to: to + 1 }, }; }