import { type AdminApiTransport, adminApiChildCreate, adminApiList, adminApiWrite, } from "#shared/admin-api-client.ts"; import { ADMIN_API_RESOURCES, type AdminApiAttribute, type AdminApiGroup, } from "#shared/admin-api-resources.ts"; import { matchKey } from "#shared/keys.ts"; import { requireValue } from "#shared/required-value.ts"; import { writeOut } from "./io.ts"; import type { CatalogProduct } from "./product-catalog/parse.ts"; import { attributeVocabulary, type CatalogFiles, type CategoryEntry, catalogFiles, ensureConsistentAttributeSpellings, ensureUniqueTitles, type PlannedAttribute, readCategoryEntries, readProducts, } from "./product-catalog.ts"; import { conflictLine, ensureNamespaceFree, type ImportFlags, type ImportPlan, listingBody, matchedIds, optionKey, planLine, refuseChangedFile, resolveImportPlan, resolveOptionIds, storedTicketsId, withTicketsMeta, } from "./product-plan.ts"; type Report = { conflicts: number; created: { attributes: number; groups: number; listings: number; options: number; }; filesUpdated: number; /** The progress lines the run produced, in order. The entrypoint answers * with one JSON document that carries them. */ lines: string[]; skipped: number; updated: number; }; /** Record one progress line on the report. The report is the run's whole * stdout, so no line reaches the stream before the run ends. */ const line = (report: Report, text: string): void => { report.lines.push(text.trim()); }; /** The one existing entity whose folded name matches `wanted`, or undefined * when none does. Two or more matches are the operator's to separate: the * importer never picks one by API order. */ const uniqueByName = ( items: readonly T[], nameOf: (item: T) => string, wanted: string, message: (count: number) => string, ): T | undefined => { const matches = items.filter( (item) => matchKey(nameOf(item)) === matchKey(wanted), ); if (matches.length > 1) throw new Error(message(matches.length)); return matches[0]; }; /** One attribute's values: reuse the stored options, create the missing ones * (or, in plan mode, only say what would happen). */ const syncAttributeOptions = async ( transport: AdminApiTransport, name: string, values: readonly string[], attribute: AdminApiAttribute | undefined, plan: boolean, report: Report, optionIds: Map, ): Promise => { for (const value of values) { // Two options with one text inside one attribute are indistinguishable. const option = attribute ? uniqueByName( attribute.options, (item) => item.text, value, (count) => `attribute '${name}' has ${count} options named '${value}'; delete all but one on the site and rerun`, ) : undefined; if (option) { optionIds.set(optionKey(name, value), option.id); continue; } if (plan || !attribute) { line(report, `would create attribute ${name}: ${value}`); continue; } const created = await adminApiChildCreate( transport, ADMIN_API_RESOURCES.attributes, "options", attribute.id, { text: value }, ); const newOption = created.attribute.options.find( (item) => item.text === value, ); if (!newOption) throw new Error(`Option '${value}' missing after create`); optionIds.set(optionKey(name, value), newOption.id); report.created.options += 1; line(report, `created option ${name}: ${value}`); } }; /** Create the site's attributes and options; returns option id by attribute * value. Existing attributes and options are matched by name and reused, so a * rerun never duplicates them. */ const syncAttributes = async ( transport: AdminApiTransport, planned: readonly PlannedAttribute[], plan: boolean, report: Report, ): Promise> => { const existing = ( await adminApiList(transport, ADMIN_API_RESOURCES.attributes) ).attributes; const optionIds = new Map(); for (const { name, values } of planned) { // The server folds case and trims names: a same-named attribute under // another spelling is the same attribute. let attribute = uniqueByName( existing, (item) => item.name, name, (count) => `attribute '${name}' matches ${count} existing attributes; rename all but one on the site and rerun`, ); if (!attribute && !plan) { const created = await adminApiWrite( transport, ADMIN_API_RESOURCES.attributes, "create", undefined, { name }, ); attribute = created.attribute; report.created.attributes += 1; line(report, `created attribute ${name}`); } await syncAttributeOptions( transport, name, values, attribute, plan, report, optionIds, ); } return optionIds; }; /** Create the groups the categories need; returns group id by slug. The * category names arrive preflighted, so no file is read here, and the group * list arrives from the same snapshot the namespace preflight read. */ const syncGroups = async ( transport: AdminApiTransport, existing: readonly AdminApiGroup[], entries: readonly CategoryEntry[], plan: boolean, report: Report, ): Promise> => { const idsBySlug = new Map(); for (const { name, slug } of entries) { // The server folds case and trims names, so match the way it does: a // same-named group under another spelling is the same group. const group = uniqueByName( existing, (item) => item.name, name, (count) => `group '${name}' matches ${count} existing groups; rename all but one on the site and rerun`, ); if (group) { // A package is a bookable bundle, not a category: attaching imported // products to it by name would change the package's contents. if (group.is_package) { throw new Error( `group '${name}' is a package; the catalog needs a separate category group; rename one and rerun`, ); } idsBySlug.set(slug, group.id); continue; } if (plan) { line(report, `would create group ${name}`); continue; } const created = await adminApiWrite( transport, ADMIN_API_RESOURCES.groups, "create", undefined, { description: `Hire products for ${name} events.`, name, }, ); idsBySlug.set(slug, created.group.id); report.created.groups += 1; line(report, `created group ${name}`); } return idsBySlug; }; /** Import one product from its decided plan, then write the tickets ids into * the file so a rerun skips it. The text is the snapshot every decision read, * so a file an editor touches mid-run is stamped from the same content the * listing was written from. */ /** The report line one saved listing produces. */ const listingReportLine = ( plan: ImportPlan, product: CatalogProduct, saved: { listing: { id: number } }, ): string => `${ plan.action === "update" ? "updated" : "created" } listing ${product.title} -> id ${saved.listing.id}`; const importProduct = async ( transport: AdminApiTransport, product: CatalogProduct, text: string, plan: ImportPlan, files: CatalogFiles, optionIds: ReadonlyMap, groupIdsBySlug: ReadonlyMap, flags: ImportFlags, report: Report, ): Promise => { const relative = `src/products/${product.filename}.md`; const file = await files.verify(`${files.root}/${relative}`); if (flags.plan) { line(report, planLine(plan, product)); if (plan.action === "skip-imported") report.skipped += 1; if (plan.action === "skip-conflict" || plan.action === "skip-ambiguous") { report.conflicts += 1; } return; } if (plan.action === "skip-imported") { report.skipped += 1; return; } if (plan.action === "skip-conflict") { report.conflicts += 1; line(report, conflictLine(product, plan.listingId, false)); return; } if (plan.action === "skip-ambiguous") { report.conflicts += 1; line( report, `skipped listing ${product.title}: the name matches listings ` + `${plan.listingIds.join(" and ")}; rename one on the site and rerun`, ); return; } const body = listingBody( product, resolveOptionIds(product, optionIds), [...new Set(product.categories)].map((slug) => requireValue( groupIdsBySlug.get(slug), `group '${slug}' has no id after the group sync`, ), ), ); const saved = await adminApiWrite( transport, ADMIN_API_RESOURCES.listings, plan.action === "update" ? "update" : "create", plan.action === "update" ? plan.listingId : undefined, body, ); if (plan.action === "update") report.updated += 1; else report.created.listings += 1; line(report, listingReportLine(plan, product, saved)); refuseChangedFile( file, text, await files.read(`${files.root}/${relative}`), saved.listing.id, ); await Deno.writeTextFile( file, withTicketsMeta(text, { id: saved.listing.id, slug: saved.listing.slug, }), ); report.filesUpdated += 1; }; /** One import run against one API client: read the catalog, plan, sync, and * report. Split from the entry point so tests can run it against a stubbed * client. */ export const runImport = async ( flags: ImportFlags & { dir: string }, transport: AdminApiTransport, ): Promise => { const files = await catalogFiles(flags.dir); const catalog = await readProducts(files, `${flags.dir}/src/products`); ensureUniqueTitles(catalog.map(({ product }) => product)); // The category titles must be the catalog's own before the import reads // anything from the site, so they resolve here; only the active products' // titles sync after the reads. await readCategoryEntries( files, `${flags.dir}/src/categories`, catalog.flatMap(({ product }) => product.categories), ); const existing = (await adminApiList(transport, ADMIN_API_RESOURCES.listings)) .listings; const existingGroups = ( await adminApiList(transport, ADMIN_API_RESOURCES.groups) ).groups; // Every plan is decided before the first write: only products that will be // created or updated drive the attribute, option, and group syncs, so a // rerun cannot leave site records behind for products it then skips. const plans = new Map( catalog.map(({ product, text }) => { const storedId = storedTicketsId(product.filename, text); return [ product.filename, resolveImportPlan( storedId === null ? null : { filename: product.filename, id: storedId, title: product.title, }, matchedIds(product.title, existing), flags.update, existing, ), ]; }), ); const active = catalog.filter(({ product }) => { const plan = plans.get(product.filename)!; return plan.action === "create" || plan.action === "update"; }); const report: Report = { conflicts: 0, created: { attributes: 0, groups: 0, listings: 0, options: 0 }, filesUpdated: 0, lines: [], skipped: 0, updated: 0, }; // Preflighted before syncAttributes: the names must exist before the // import changes anything on the site. const categoryEntries = await readCategoryEntries( files, `${flags.dir}/src/categories`, [...new Set(active.flatMap(({ product }) => product.categories))], ); // Both preflights read nothing but the two name snapshots, and both must // pass before the first write: a refusal here leaves the site untouched. ensureNamespaceFree( active.map(({ product }) => product), categoryEntries, existing, existingGroups, ); ensureConsistentAttributeSpellings(active.map(({ product }) => product)); const optionIds = await syncAttributes( transport, attributeVocabulary(active.map(({ product }) => product)), flags.plan, report, ); const groupIdsBySlug = await syncGroups( transport, existingGroups, categoryEntries, flags.plan, report, ); for (const { product, text } of catalog) { await importProduct( transport, product, text, plans.get(product.filename)!, files, optionIds, groupIdsBySlug, flags, report, ); } await writeOut( `${JSON.stringify({ conflicts: report.conflicts, created: report.created, filesUpdated: report.filesUpdated, lines: report.lines, plan: flags.plan, skipped: report.skipped, updated: report.updated, })}\n`, ); return report; };