import { CliError } from "#cli/errors.ts"; import { matchKey } from "#shared/keys.ts"; import { type CatalogProduct, frontmatterBlock, } from "./product-catalog/parse.ts"; import type { CategoryEntry } from "./product-catalog.ts"; /** A row the importer matches by name: the listing list the API returns. */ export type ApiNamedLike = { id: number; name: string }; /** The ids of the listings the site considers the same name as the title: the * server's name registry trims and folds case, so the importer matches the * same way instead of failing a create with a duplicate. */ export const matchedIds = ( title: string, existing: readonly ApiNamedLike[], ): number[] => existing .filter((listing) => matchKey(listing.name) === matchKey(title)) .map((listing) => listing.id); /** The server keeps one namespace for listings and groups, so a create that * takes the other kind's name fails after the import has already written * attributes, options, and groups. Refuse every such name before the first * write: a product title that a group holds, a product title that a category * of the same catalog holds, a category name that a listing holds, and two * categories under one folded name. A product title that an existing listing * holds is not refused here: that match is the plan's own conflict path. */ export const ensureNamespaceFree = ( products: readonly CatalogProduct[], categoryEntries: readonly CategoryEntry[], listings: readonly ApiNamedLike[], groups: readonly ApiNamedLike[], ): void => { const byFoldedName = ( rows: readonly ApiNamedLike[], ): Map => { const index = new Map(); for (const row of rows) index.set(matchKey(row.name), row); return index; }; const groupsByName = byFoldedName(groups); const listingsByName = byFoldedName(listings); const categoryByName = new Map(); for (const entry of categoryEntries) { const key = matchKey(entry.name); const seen = categoryByName.get(key); if (seen !== undefined) { throw new Error( `the categories '${seen.slug}' and '${entry.slug}' are both named '${entry.name}'; rename one and rerun`, ); } categoryByName.set(key, entry); } for (const product of products) { const group = groupsByName.get(matchKey(product.title)); if (group !== undefined) { throw new Error( `a group named '${group.name}' already exists (id ${group.id}); a listing cannot take a group's name, so rename one and rerun`, ); } const category = categoryByName.get(matchKey(product.title)); if (category !== undefined) { throw new Error( `the product '${product.title}' and the category '${category.name}' share a name; rename one and rerun`, ); } } for (const entry of categoryEntries) { const listing = listingsByName.get(matchKey(entry.name)); if (listing !== undefined) { throw new Error( `a listing named '${listing.name}' already exists (id ${listing.id}); a group cannot take a listing's name, so rename one and rerun`, ); } } }; /** The flags the Markdown Frontmatter Importer accepts. */ export type ImportFlags = { dir: string | undefined; plan: boolean; update: boolean; }; /** Read the importer's flags. `--dir` needs its own operand: a missing one or * one that reads as another flag is an error, not a silent default. Any * other argument is an error too, so a mistyped flag cannot silently change * nothing. */ export const parseImportFlags = (args: readonly string[]): ImportFlags => { const flags: ImportFlags = { dir: undefined, plan: false, update: false }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--plan") flags.plan = true; else if (arg === "--update") flags.update = true; else if (arg === "--dir") { const value = args[index + 1]; if (value === undefined || value.startsWith("-")) { throw new CliError("usage", "--dir requires a directory"); } flags.dir = value; index += 1; } else { throw new CliError("usage", `unknown argument: ${arg}`); } } return flags; }; /** Minor-unit price for a whole-pound hire price. */ export const minorUnits = (pounds: number): number => Math.round(pounds * 100); /** The booking-page description: the site's own subtitle, spec list, and * what-is-included list, in that order. */ export const composeDescription = (product: CatalogProduct): string => { const parts: string[] = []; if (product.subtitle !== "") parts.push(product.subtitle); if (product.specs.length > 0) { parts.push( [ "### Specifications", ...product.specs.map((spec) => `- ${spec.name}: ${spec.value}`), ].join("\n"), ); } if (product.features.length > 0) { parts.push( [ "### What's included", ...product.features.map((feature) => `- ${feature}`), ].join("\n"), ); } return parts.join("\n\n"); }; /** The listing the admin JSON API accepts for one product. Prices are minor * units; the day-count prices carry the rental-period options. `optionIds` * and `groupIds` are the resolved ids of the product's attribute selection and * category memberships. */ export const listingBody = ( product: CatalogProduct, optionIds: readonly number[], groupIds: readonly number[], ) => { const maxDays = Math.max(...product.options.map((option) => option.days)); const dayPrices = Object.fromEntries( product.options.map((option) => [ String(option.days), minorUnits(option.unit_price), ]), ); // The listing's own cap is the strictest option's: one selector governs // every day count, so the loosest option's limit must not override a // tighter one. const maxQuantity = Math.min( ...product.options.map((option) => option.max_quantity), ); // The site caps a daily listing's bookable quantity per date by // max_attendees, so the catalog's own quantity limit is the capacity: a // fixed 1 would leave every multi-unit option unable to sell past one // unit on a date. return { attribute_option_ids: [...optionIds], customisable_days: true, day_prices: dayPrices, description: composeDescription(product), duration_days: maxDays, fields: "email,phone,address", group_ids: [...groupIds], hidden: true, listing_type: "daily" as const, max_attendees: maxQuantity, max_quantity: maxQuantity, name: product.title, }; }; /** Insert or replace the tickets ids in a product file's frontmatter, before * the closing fence. A stored placeholder (`tickets_id:` with no value) is * replaced like any stored value, so a partly stamped file still stamps. * Only the frontmatter block is touched: a body that mentions tickets_id * stays as it is. */ export const withTicketsMeta = ( text: string, meta: { id: number; slug: string }, ): string => { // The file keeps its own line endings: a stamp must stay a two-line // change, not rewrite every body line of a CRLF file. const eol = text.includes("\r\n") ? "\r\n" : "\n"; const normalized = text.replaceAll("\r\n", "\n"); const [full, frontBlock] = /^---\n([\s\S]*?)\n---/.exec(normalized) ?? []; if (full === undefined || frontBlock === undefined) return text; const front = frontBlock .split("\n") .filter((line) => !/^tickets_(id|slug):/.test(line)) .join("\n"); const insertion = `tickets_id: ${meta.id}\ntickets_slug: ${meta.slug}`; return `---\n${front}\n${insertion}\n---${normalized.slice(full.length)}`.replaceAll( "\n", eol, ); }; /** The option id of one attribute value, keyed "AttributeName\nOptionText". */ export const optionKey = (name: string, value: string): string => `${name}\n${value}`; /** Refuse to stamp a file whose text changed since the import read it: the * stamp is written from the snapshot every decision read, so the write * would discard the editor's newer content. The listing already exists, so * the refusal names its id and the way to stamp the file afterwards. */ export const refuseChangedFile = ( file: string, snapshot: string, current: string, listingId: number, ): void => { if (snapshot === current) return; throw new Error( `${file} changed while the import ran; the listing holds id ${listingId}. Add the tickets_id and tickets_slug lines to the file by hand, or rerun with --update to stamp it`, ); }; /** The option ids a product selects, in the order its file lists them. The * importer creates every option the catalog vocabulary plans, so a missing id * names a real gap and stops the import. */ export const resolveOptionIds = ( product: CatalogProduct, optionIds: ReadonlyMap, ): number[] => product.filterAttributes.map((attribute) => { const id = optionIds.get(optionKey(attribute.name, attribute.value)); if (id === undefined) { throw new Error( `${product.filename}: no option id for ${attribute.name}: ${attribute.value}`, ); } return id; }); /** The tickets id a product file already stores, when it has one. */ export const storedTicketsId = ( filename: string, text: string, ): number | null => { // Only the frontmatter block counts: a body line that repeats the field // name must not make the importer skip a product it never imported. const block = frontmatterBlock(text); if (block === null) return null; const blank = block.match(/^tickets_id:\s*$/m); if (blank) return null; const match = block.match(/^tickets_id: (.+)$/m); if (!match || match[1] === undefined) return null; const id = Number(match[1]); // A stored id names one listing; anything that cannot be one must stop the // import instead of reading as absent and creating a duplicate. if (!Number.isSafeInteger(id) || id < 1) { throw new Error( `${filename}: tickets_id must be a positive whole number, not ${match[1].trim()}`, ); } return id; }; /** What the importer may do with one product file. A skip-conflict, * skip-ambiguous, or update plan carries the matched listing's ids, because * only those actions need them. */ export type ImportPlan = | { action: "create" } | { action: "skip-ambiguous"; listingIds: number[] } | { action: "skip-conflict"; listingId: number } | { action: "skip-imported" } | { action: "update"; listingId: number }; /** Decide one product's action. A file that stores a tickets id is imported * only when the stamp names a live listing of the product's own name: the * site may have deleted the listing, or the import may point at another * site, and a skipped file would keep the invalid reference forever. A * title that matches one existing listing is the operator's choice: the * importer never overwrites an unrelated listing without --update. A title * that matches several is always skipped: the operator renames one first. */ export const resolveImportPlan = ( stored: { filename: string; id: number; title: string } | null, matchedIds: readonly number[], update: boolean, listings: readonly ApiNamedLike[], ): ImportPlan => { if (stored !== null) { const listing = listings.find((item) => item.id === stored.id); if (listing === undefined) { throw new Error( `${stored.filename}: tickets_id ${stored.id} names no listing on the site; restore the listing or delete the stamp from the file`, ); } if (matchKey(listing.name) !== matchKey(stored.title)) { throw new Error( `${stored.filename}: tickets_id ${stored.id} names listing '${listing.name}', not '${stored.title}'; delete the stamp from the file or rename one and rerun`, ); } return { action: "skip-imported" }; } const [firstMatch] = matchedIds; if (firstMatch === undefined) return { action: "create" }; if (matchedIds.length > 1) { return { action: "skip-ambiguous", listingIds: [...matchedIds] }; } return update ? { action: "update", listingId: firstMatch } : { action: "skip-conflict", listingId: firstMatch }; }; /** The line a skipped name conflict prints; both modes tell the operator how * to go ahead. */ export const conflictLine = ( product: CatalogProduct, listingId: number, plan: boolean, ): string => `${plan ? "would skip" : "skipped"} listing ${product.title}: the name matches listing ${listingId}; rerun with --update to overwrite it\n`; /** The line plan mode prints for one product. The attribute count is the * product's own selection: a real run attaches every filter attribute, whether * or not the plan has created their options yet. */ export const planLine = (plan: ImportPlan, product: CatalogProduct): string => { const summary = `listing ${product.title} ` + `(${product.options.length} rental options, ${product.filterAttributes.length} attributes)`; switch (plan.action) { case "create": return `would create ${summary}\n`; case "skip-ambiguous": return ( `would skip listing ${product.title}: the name matches listings ` + `${plan.listingIds.join(" and ")}; rename one on the site and rerun\n` ); case "skip-conflict": return conflictLine(product, plan.listingId, true); case "skip-imported": return `already imported: ${product.title}\n`; case "update": return `would update ${summary}\n`; } };