cli/import-run.ts
Rama main · réplica de lectura del repositorio original.
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 = <T>(
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<string, number>,
): Promise<void> => {
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<Map<string, number>> => {
const existing = (
await adminApiList(transport, ADMIN_API_RESOURCES.attributes)
).attributes;
const optionIds = new Map<string, number>();
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<Map<string, number>> => {
const idsBySlug = new Map<string, number>();
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<string, number>,
groupIdsBySlug: ReadonlyMap<string, number>,
flags: ImportFlags,
report: Report,
): Promise<void> => {
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<Report> => {
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<string, ImportPlan>(
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;
};