cli/product-plan.ts
Rama main · réplica de lectura del repositorio original.
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<string, ApiNamedLike> => {
const index = new Map<string, ApiNamedLike>();
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<string, CategoryEntry>();
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<string, number>,
): 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`;
}
};