tablefacts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +313 -0
- package/bin/tablefacts.mjs +28 -0
- package/package.json +77 -0
- package/src/index.mjs +52 -0
- package/src/instagram/README.md +135 -0
- package/src/instagram/download.mjs +62 -0
- package/src/instagram/index.mjs +355 -0
- package/src/instagram/links.mjs +55 -0
- package/src/instagram/record.mjs +18 -0
- package/src/lib/edge.mjs +49 -0
- package/src/lib/env.mjs +35 -0
- package/src/lib/errors.mjs +39 -0
- package/src/lib/files.mjs +10 -0
- package/src/lib/images.mjs +20 -0
- package/src/lib/log.mjs +17 -0
- package/src/lib/photos.mjs +42 -0
- package/src/lib/playwright.mjs +13 -0
- package/src/lib/project.mjs +42 -0
- package/src/lib/text.mjs +7 -0
- package/src/lib/types.mjs +247 -0
- package/src/menu/README.md +97 -0
- package/src/menu/cluvi/config.mjs +33 -0
- package/src/menu/cluvi/extract.mjs +24 -0
- package/src/menu/cluvi/import.mjs +30 -0
- package/src/menu/cluvi/source.mjs +156 -0
- package/src/menu/index.mjs +9 -0
- package/src/menu/lib/db.mjs +119 -0
- package/src/menu/lib/import.mjs +126 -0
- package/src/menu/lib/menu.mjs +95 -0
- package/src/menu/lib/run.mjs +83 -0
- package/src/menu/raw/config.mjs +38 -0
- package/src/menu/raw/extract.mjs +72 -0
- package/src/menu/raw/import.mjs +99 -0
- package/src/menu/raw/normalize.mjs +120 -0
- package/src/menu/raw/source.mjs +116 -0
- package/src/menu/raw/vision.mjs +252 -0
- package/src/research/README.md +69 -0
- package/src/research/index.mjs +147 -0
- package/src/research/lib/google.mjs +93 -0
- package/src/research/lib/hours.mjs +109 -0
- package/src/research/lib/merge.mjs +119 -0
- package/src/research/lib/osm.mjs +49 -0
- package/src/research/lib/report.mjs +118 -0
- package/src/research/lib/social.mjs +49 -0
- package/src/research/lib/util.mjs +104 -0
- package/src/research/lib/website.mjs +285 -0
- package/src/research/research.mjs +67 -0
- package/src/tripadvisor/README.md +78 -0
- package/src/tripadvisor/index.mjs +178 -0
- package/src/tripadvisor/links.mjs +78 -0
- package/src/tripadvisor/photos.mjs +55 -0
- package/types/index.d.mts +65 -0
- package/types/instagram/download.d.mts +1 -0
- package/types/instagram/index.d.mts +13 -0
- package/types/instagram/links.d.mts +14 -0
- package/types/instagram/record.d.mts +1 -0
- package/types/lib/edge.d.mts +14 -0
- package/types/lib/env.d.mts +12 -0
- package/types/lib/errors.d.mts +25 -0
- package/types/lib/files.d.mts +1 -0
- package/types/lib/images.d.mts +6 -0
- package/types/lib/log.d.mts +5 -0
- package/types/lib/photos.d.mts +30 -0
- package/types/lib/playwright.d.mts +1677 -0
- package/types/lib/project.d.mts +32 -0
- package/types/lib/text.d.mts +4 -0
- package/types/lib/types.d.mts +668 -0
- package/types/menu/cluvi/config.d.mts +13 -0
- package/types/menu/cluvi/extract.d.mts +2 -0
- package/types/menu/cluvi/import.d.mts +35 -0
- package/types/menu/cluvi/source.d.mts +37 -0
- package/types/menu/index.d.mts +6 -0
- package/types/menu/lib/db.d.mts +23 -0
- package/types/menu/lib/import.d.mts +11 -0
- package/types/menu/lib/menu.d.mts +24 -0
- package/types/menu/lib/run.d.mts +27 -0
- package/types/menu/raw/config.d.mts +18 -0
- package/types/menu/raw/extract.d.mts +2 -0
- package/types/menu/raw/import.d.mts +64 -0
- package/types/menu/raw/normalize.d.mts +29 -0
- package/types/menu/raw/source.d.mts +19 -0
- package/types/menu/raw/vision.d.mts +13 -0
- package/types/research/index.d.mts +9 -0
- package/types/research/lib/google.d.mts +12 -0
- package/types/research/lib/hours.d.mts +22 -0
- package/types/research/lib/merge.d.mts +87 -0
- package/types/research/lib/osm.d.mts +36 -0
- package/types/research/lib/report.d.mts +6 -0
- package/types/research/lib/social.d.mts +118 -0
- package/types/research/lib/util.d.mts +45 -0
- package/types/research/lib/website.d.mts +283 -0
- package/types/research/research.d.mts +1 -0
- package/types/tripadvisor/index.d.mts +11 -0
- package/types/tripadvisor/links.d.mts +13 -0
- package/types/tripadvisor/photos.d.mts +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Everything restaurant-specific about the Cluvi import. Edit this file, not
|
|
2
|
+
// source.mjs, when the menu is organised differently.
|
|
3
|
+
export default {
|
|
4
|
+
// Any page of the restaurant's Cluvi menu. Only the first path segment is
|
|
5
|
+
// used (the supplier, here "cannario"). `tablefacts menu cluvi <url>`
|
|
6
|
+
// overrides it for one run.
|
|
7
|
+
url: "https://cannario.cluvi.co/cannario/maincategories",
|
|
8
|
+
|
|
9
|
+
// Cluvi groups products as main category > subcategory. The site groups them
|
|
10
|
+
// as category > section, with three categories by default (cocina, brunch,
|
|
11
|
+
// bar), so each main category is folded into one of these. `from` lists
|
|
12
|
+
// Cluvi main categories, ignoring case and accents. One that is listed
|
|
13
|
+
// nowhere becomes a category of its own (slug from its name); that works,
|
|
14
|
+
// but the slug then needs its keys in the dictionaries and in content/qr.ts.
|
|
15
|
+
// A category left with no products is not written.
|
|
16
|
+
categories: [
|
|
17
|
+
{ slug: "cocina", name: "Comida", from: ["Entradas", "Fuertes", "Postres"] },
|
|
18
|
+
{
|
|
19
|
+
slug: "bar",
|
|
20
|
+
name: "Bebidas",
|
|
21
|
+
from: ["Cócteles Cannario", "Tardeo Cannario", "Vinos", "Licores", "Bebidas"],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
|
|
25
|
+
// Cluvi subcategory > section name, for the ones that should not just be
|
|
26
|
+
// the subcategory name in UPPERCASE. These two match keys of `menu.sections`
|
|
27
|
+
// in dictionary.ts, and the home page cocktail list reads signatureMenu
|
|
28
|
+
// ("COCTELES DE AUTOR") from content/site.ts.
|
|
29
|
+
sections: {
|
|
30
|
+
"Cócteles Autor Cannario": "COCTELES DE AUTOR",
|
|
31
|
+
Mocktails: "MOCKTAIL",
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Imports a restaurant's menu from Cluvi into the Supabase menu tables.
|
|
3
|
+
// See src/menu/README.md. Run from the project's folder:
|
|
4
|
+
// tablefacts menu cluvi --dry-run
|
|
5
|
+
import { runImport } from "../lib/run.mjs";
|
|
6
|
+
import { fetchCluviMenu } from "./import.mjs";
|
|
7
|
+
|
|
8
|
+
const usage = `Usage: tablefacts menu cluvi [menu-url] [options]
|
|
9
|
+
|
|
10
|
+
Reads the menu of a restaurant on Cluvi and replaces it in Supabase.
|
|
11
|
+
menu-url any page of the restaurant's Cluvi menu (default: config.mjs)
|
|
12
|
+
|
|
13
|
+
Cluvi options:
|
|
14
|
+
--service <name> which Cluvi menu to read: on_table (default), delivery, take_away
|
|
15
|
+
--lang <code> language of the Cluvi menu (default: es)`;
|
|
16
|
+
|
|
17
|
+
await runImport({
|
|
18
|
+
usage,
|
|
19
|
+
options: {
|
|
20
|
+
service: { type: "string", default: "on_table" },
|
|
21
|
+
lang: { type: "string", default: "es" },
|
|
22
|
+
},
|
|
23
|
+
fetchMenu: ({ values, positionals }) => fetchCluviMenu({ url: positionals[0], service: values.service, lang: values.lang }),
|
|
24
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Cluvi as a library: read the menu of a restaurant and import it.
|
|
2
|
+
import { TablefactsError } from "../../lib/errors.mjs";
|
|
3
|
+
import { importMenu } from "../lib/import.mjs";
|
|
4
|
+
import defaultConfig from "./config.mjs";
|
|
5
|
+
import { fetchCluvi, normalizeCluvi } from "./source.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads and normalizes the menu: `{ menu, notes, title }`, ready for importMenu.
|
|
9
|
+
* `config` is the restaurant-specific part (see config.mjs); the default is that file's.
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchCluviMenu({ url, service, lang, config = defaultConfig } = {}) {
|
|
12
|
+
if (!(url ?? config.url)) throw new TablefactsError("No Cluvi menu URL: pass url, or set url in the cluvi config.", "ECONFIG");
|
|
13
|
+
const fetched = await fetchCluvi({ url: url ?? config.url, service, lang });
|
|
14
|
+
const { menu, notes, currency } = normalizeCluvi(fetched, config);
|
|
15
|
+
const { supplier } = fetched;
|
|
16
|
+
return {
|
|
17
|
+
menu,
|
|
18
|
+
notes,
|
|
19
|
+
title: `Cluvi: ${supplier.label} (id ${supplier.id}), ${fetched.service} menu in ${currency}`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads the menu from Cluvi and imports it. Takes importMenu's options too.
|
|
25
|
+
* @param {import('../../lib/types.mjs').ImportCluviOptions} [options]
|
|
26
|
+
* @returns {Promise<import('../../lib/types.mjs').ImportResult>}
|
|
27
|
+
*/
|
|
28
|
+
export async function importCluvi({ url, service, lang, config, ...importOptions } = {}) {
|
|
29
|
+
return importMenu({ ...(await fetchCluviMenu({ url, service, lang, config })), ...importOptions });
|
|
30
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Reads a restaurant's menu from Cluvi (cluvi.com), which serves digital menus
|
|
2
|
+
// as a single-page app at <restaurant>.cluvi.co. The page has no menu markup
|
|
3
|
+
// and Cluvi publishes no API, so this calls the two JSON endpoints the app
|
|
4
|
+
// itself calls. They are not a documented interface and may change: if the
|
|
5
|
+
// import breaks, check these URLs against the network tab of the menu page.
|
|
6
|
+
//
|
|
7
|
+
// supplier https://exp2.cluvi.com/api/suppliers/<slug>.json id, currency
|
|
8
|
+
// menu https://services.cluvi.com/v1/menu/<id>/<service>.json categories + products
|
|
9
|
+
import { cleanText, htmlToText, isCurrency, matchKey, sectionName, slugify } from "../lib/menu.mjs";
|
|
10
|
+
import { TablefactsError } from "../../lib/errors.mjs";
|
|
11
|
+
|
|
12
|
+
const SUPPLIER_URL = "https://exp2.cluvi.com/api/suppliers";
|
|
13
|
+
const MENU_URL = "https://services.cluvi.com/v1/menu";
|
|
14
|
+
const headers = { accept: "application/json", "user-agent": "cannario-menu-sync/1.0 (restaurant menu importer)" };
|
|
15
|
+
|
|
16
|
+
async function getJson(url) {
|
|
17
|
+
let failure;
|
|
18
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
|
|
21
|
+
if (res.ok) return await res.json();
|
|
22
|
+
failure = Object.assign(new TablefactsError(`HTTP ${res.status} from ${url}`, "EFAILED"), { status: res.status });
|
|
23
|
+
if (res.status < 500) break; // a client error will not fix itself
|
|
24
|
+
} catch (error) {
|
|
25
|
+
failure = error; // network error, timeout or a body that is not JSON
|
|
26
|
+
}
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
|
|
28
|
+
}
|
|
29
|
+
throw failure;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** https://<restaurant>.cluvi.co/<supplier>/maincategories gives "<supplier>". */
|
|
33
|
+
export function supplierSlug(menuUrl) {
|
|
34
|
+
let slug;
|
|
35
|
+
try {
|
|
36
|
+
slug = new URL(menuUrl).pathname.split("/").filter(Boolean)[0];
|
|
37
|
+
} catch {
|
|
38
|
+
throw new TablefactsError(`"${menuUrl}" is not a URL.`, "EUSAGE");
|
|
39
|
+
}
|
|
40
|
+
if (!slug) {
|
|
41
|
+
throw new TablefactsError(`"${menuUrl}" has no supplier in its path. Use a menu page such as https://<restaurant>.cluvi.co/<supplier>/maincategories.`, "EUSAGE");
|
|
42
|
+
}
|
|
43
|
+
return slug;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `service` picks which of the restaurant's menus to read. `on_table` is the
|
|
48
|
+
* digital menu guests open from the table QR code and is the complete one:
|
|
49
|
+
* `delivery` and `take_away` only list what the restaurant sells that way.
|
|
50
|
+
*/
|
|
51
|
+
export async function fetchCluvi({ url, service = "on_table", lang = "es" }) {
|
|
52
|
+
const slug = supplierSlug(url);
|
|
53
|
+
const unknown = new TablefactsError(`Cluvi has no restaurant "${slug}". Check the first path segment of the menu URL.`, "EFAILED");
|
|
54
|
+
let supplier;
|
|
55
|
+
try {
|
|
56
|
+
supplier = await getJson(`${SUPPLIER_URL}/${slug}.json`);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw error.status === 404 ? unknown : error;
|
|
59
|
+
}
|
|
60
|
+
if (!supplier?.id) throw unknown; // an unknown slug is answered with 200 and {}
|
|
61
|
+
const raw = await getJson(`${MENU_URL}/${supplier.id}/${encodeURIComponent(service)}.json?lang=${encodeURIComponent(lang)}`);
|
|
62
|
+
return { supplier, raw, service };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Photos come as the same file at several widths. The largest is the best
|
|
66
|
+
// source for next/image, which resizes it again.
|
|
67
|
+
const imageWidths = ["w_1200", "w_992", "w_768", "w_576", "blog", "thumb"];
|
|
68
|
+
|
|
69
|
+
function imageUrl(image) {
|
|
70
|
+
if (typeof image === "string") return image.startsWith("https://") ? image : null;
|
|
71
|
+
return imageWidths.map((width) => image?.[width]).find((url) => typeof url === "string" && url.startsWith("https://")) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const byOrder = (a, b) => (Number(a.order) || 0) - (Number(b.order) || 0);
|
|
75
|
+
const leaves = (category) => (category.subcategories?.length ? category.subcategories.flatMap(leaves) : [category]);
|
|
76
|
+
const listNames = (names) => names.slice(0, 8).join(", ") + (names.length > 8 ? `, and ${names.length - 8} more` : "");
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cluvi's response to the shape lib/menu.mjs describes. Main categories are
|
|
80
|
+
* folded into the categories of `config.categories`, subcategories become
|
|
81
|
+
* sections, and products keep the order Cluvi shows them in (their `order`
|
|
82
|
+
* field, not their position in the subcategory's product_ids).
|
|
83
|
+
*/
|
|
84
|
+
export function normalizeCluvi({ supplier, raw }, config) {
|
|
85
|
+
const { categories, products } = raw?.menu ?? {};
|
|
86
|
+
if (!Array.isArray(categories) || !Array.isArray(products)) {
|
|
87
|
+
throw new TablefactsError("Cluvi returned a menu in an unexpected shape (no menu.categories or menu.products). Its API may have changed: see src/menu/cluvi/source.mjs.", "EFAILED");
|
|
88
|
+
}
|
|
89
|
+
const currency = [supplier.currency, raw.customer?.currency].map((code) => String(code ?? "").toUpperCase()).find(isCurrency);
|
|
90
|
+
if (!currency) throw new TablefactsError("Cluvi did not say which currency this menu uses.", "EFAILED");
|
|
91
|
+
|
|
92
|
+
const notes = [];
|
|
93
|
+
const byId = new Map(products.map((product) => [product.id, product]));
|
|
94
|
+
const renames = new Map(Object.entries(config.sections ?? {}).map(([from, to]) => [matchKey(from), to]));
|
|
95
|
+
const groups = (config.categories ?? []).map((c) => ({ slug: c.slug, name: c.name, from: new Set(c.from.map(matchKey)), sections: new Map() }));
|
|
96
|
+
const own = new Map(); // main categories that config.categories does not list
|
|
97
|
+
const placed = new Set();
|
|
98
|
+
const unpriced = [];
|
|
99
|
+
const withOptions = [];
|
|
100
|
+
let soldOut = 0;
|
|
101
|
+
|
|
102
|
+
const ownCategory = (main) => {
|
|
103
|
+
const slug = slugify(main.label) || `category-${main.id}`;
|
|
104
|
+
if (!own.has(slug)) {
|
|
105
|
+
own.set(slug, { slug, name: cleanText(main.label), sections: new Map() });
|
|
106
|
+
notes.push(`Cluvi category "${cleanText(main.label)}" is not in config.categories: imported as its own category "${slug}".`);
|
|
107
|
+
}
|
|
108
|
+
return own.get(slug);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function convert(product) {
|
|
112
|
+
const name = cleanText(product.label);
|
|
113
|
+
if (!name) return null;
|
|
114
|
+
const price = Number(product.price);
|
|
115
|
+
if (!(price > 0)) unpriced.push(name);
|
|
116
|
+
if (product.configs?.length) withOptions.push(name);
|
|
117
|
+
if (product.out_of_stock) soldOut++;
|
|
118
|
+
return {
|
|
119
|
+
name,
|
|
120
|
+
description: htmlToText(product.description),
|
|
121
|
+
price: price > 0 ? Math.round(price * 100) / 100 : 0,
|
|
122
|
+
currency,
|
|
123
|
+
image_url: imageUrl(product.image),
|
|
124
|
+
recommended: product.important === true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const main of [...categories].sort(byOrder)) {
|
|
129
|
+
const category = groups.find((group) => group.from.has(matchKey(main.label))) ?? ownCategory(main);
|
|
130
|
+
for (const sub of leaves(main).sort(byOrder)) {
|
|
131
|
+
const name = renames.get(matchKey(sub.label)) ?? sectionName(sub.label || main.label);
|
|
132
|
+
if (!category.sections.has(name)) category.sections.set(name, []);
|
|
133
|
+
const items = (sub.product_ids ?? []).map((id, index) => ({ product: byId.get(id), index }));
|
|
134
|
+
for (const { product } of items.filter((item) => item.product).sort((a, b) => byOrder(a.product, b.product) || a.index - b.index)) {
|
|
135
|
+
placed.add(product.id);
|
|
136
|
+
const converted = convert(product);
|
|
137
|
+
if (converted) category.sections.get(name).push(converted);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const unplaced = products.filter((product) => !placed.has(product.id));
|
|
143
|
+
if (unplaced.length) notes.push(`${unplaced.length} products are in no category and were skipped: ${listNames(unplaced.map((p) => cleanText(p.label)))}.`);
|
|
144
|
+
if (unpriced.length) notes.push(`${unpriced.length} products have no price in Cluvi, which hides a price of 0. They are imported with price 0 and the site will show 0: ${listNames(unpriced)}.`);
|
|
145
|
+
if (withOptions.length) notes.push(`${withOptions.length} products have options in Cluvi (add-ons, sizes) that the menu tables cannot hold, so only their base price is imported.`);
|
|
146
|
+
if (soldOut) notes.push(`${soldOut} products are marked sold out in Cluvi and were imported anyway.`);
|
|
147
|
+
|
|
148
|
+
const menu = [...groups, ...own.values()]
|
|
149
|
+
.map((c) => ({
|
|
150
|
+
slug: c.slug,
|
|
151
|
+
name: c.name,
|
|
152
|
+
sections: [...c.sections].filter(([, list]) => list.length).map(([name, list]) => ({ name, products: list })),
|
|
153
|
+
}))
|
|
154
|
+
.filter((c) => c.sections.length);
|
|
155
|
+
return { menu, notes, currency };
|
|
156
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The menu importer as a library. Silent unless given a `log` function; it
|
|
2
|
+
// never reads process.argv, loads .env or sets an exit code. Importing it runs
|
|
3
|
+
// nothing (pg loads only when a database is reached).
|
|
4
|
+
export { importMenu } from "./lib/import.mjs";
|
|
5
|
+
export { importCluvi } from "./cluvi/import.mjs";
|
|
6
|
+
export { importImageMenu, listMenuImages } from "./raw/import.mjs";
|
|
7
|
+
export { cleanText, countMenu, sectionName, slugify, validateMenu } from "./lib/menu.mjs";
|
|
8
|
+
export { normalizePages, parsePrice } from "./raw/normalize.mjs";
|
|
9
|
+
export { defaultProvider, providers } from "./raw/vision.mjs";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Writes a menu (see lib/menu.mjs) into the Supabase tables of
|
|
2
|
+
// supabase/migrations/0001_menu.sql over a direct Postgres connection.
|
|
3
|
+
//
|
|
4
|
+
// The connection string (SUPABASE_DB_URL) is a privileged credential: it
|
|
5
|
+
// bypasses row level security, which only allows public reads. It lives in
|
|
6
|
+
// .env, which is git-ignored, and is never read by the site: do not copy
|
|
7
|
+
// it into frontend/.env or into a NEXT_PUBLIC_ variable.
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { resolveEnv } from "../../lib/env.mjs";
|
|
10
|
+
import { TablefactsError } from "../../lib/errors.mjs";
|
|
11
|
+
|
|
12
|
+
/** Opens the connection. `label` names the target without the credentials. */
|
|
13
|
+
export async function connect(url, { env } = {}) {
|
|
14
|
+
url ??= resolveEnv(env).SUPABASE_DB_URL;
|
|
15
|
+
if (!/^postgres(ql)?:\/\//i.test(url ?? "")) {
|
|
16
|
+
throw new TablefactsError(
|
|
17
|
+
"SUPABASE_DB_URL is not set to a postgres:// connection string.\n" +
|
|
18
|
+
"Copy .env.example to .env and paste the pooler URL from the Supabase dashboard (Connect > Transaction pooler).",
|
|
19
|
+
"ECONFIG",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (url.includes("[YOUR-PASSWORD]")) {
|
|
23
|
+
throw new TablefactsError("SUPABASE_DB_URL still contains [YOUR-PASSWORD]: replace it with the database password.", "ECONFIG");
|
|
24
|
+
}
|
|
25
|
+
let target;
|
|
26
|
+
try {
|
|
27
|
+
target = new URL(url);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new TablefactsError("SUPABASE_DB_URL is not a valid connection string (special characters in the password must be URL-encoded).", "ECONFIG");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { default: pg } = await import("pg");
|
|
33
|
+
const client = new pg.Client({
|
|
34
|
+
connectionString: url,
|
|
35
|
+
// Supabase's pooler certificate chains to Supabase's own root CA, which Node
|
|
36
|
+
// does not trust, so verification fails by default. The connection is still
|
|
37
|
+
// encrypted. Put `sslmode` in the URL to choose something else.
|
|
38
|
+
ssl: target.searchParams.has("sslmode") ? undefined : { rejectUnauthorized: false },
|
|
39
|
+
});
|
|
40
|
+
await client.connect();
|
|
41
|
+
return { client, label: `${target.hostname}:${target.port || 5432}${target.pathname}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Says what is wrong when the migration has not been applied to this database. */
|
|
45
|
+
function explain(error) {
|
|
46
|
+
if (error.code === "42P01") {
|
|
47
|
+
error.message = "The menu tables do not exist. Apply supabase/migrations/0001_menu.sql to this database first.";
|
|
48
|
+
}
|
|
49
|
+
return error;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What an import would replace: the categories it writes, or the whole menu. */
|
|
53
|
+
export async function inspect(client, menu, { replaceAll = false } = {}) {
|
|
54
|
+
const slugs = replaceAll ? null : menu.map((category) => category.slug);
|
|
55
|
+
try {
|
|
56
|
+
const { rows } = await client.query(
|
|
57
|
+
`select count(distinct c.id)::int as categories, count(p.id)::int as products
|
|
58
|
+
from public.menu_categories c
|
|
59
|
+
left join public.menu_sections s on s.category_id = c.id
|
|
60
|
+
left join public.menu_products p on p.section_id = s.id
|
|
61
|
+
where $1::text[] is null or c.slug = any($1)`,
|
|
62
|
+
[slugs],
|
|
63
|
+
);
|
|
64
|
+
const kept = slugs
|
|
65
|
+
? (await client.query("select slug from public.menu_categories where slug <> all($1) order by sort_order", [slugs])).rows.map((row) => row.slug)
|
|
66
|
+
: [];
|
|
67
|
+
return { ...rows[0], kept };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw explain(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Replaces the menu in one transaction, so readers see the old menu or the new
|
|
75
|
+
* one and a failure changes nothing. By default only the categories in `menu`
|
|
76
|
+
* are replaced (deleting a category cascades to its sections and products);
|
|
77
|
+
* `replaceAll` empties the menu first. Ids are generated here so the rows can
|
|
78
|
+
* be inserted in bulk, a column at a time.
|
|
79
|
+
*/
|
|
80
|
+
export async function replaceMenu(client, menu, { replaceAll = false } = {}) {
|
|
81
|
+
const categories = menu.map((c, i) => ({ id: randomUUID(), slug: c.slug, name: c.name, sort_order: i + 1 }));
|
|
82
|
+
const sections = menu.flatMap((c, ci) =>
|
|
83
|
+
c.sections.map((s, si) => ({ id: randomUUID(), category_id: categories[ci].id, name: s.name, sort_order: si + 1, products: s.products })),
|
|
84
|
+
);
|
|
85
|
+
// `recommended` is NOT NULL DEFAULT false, but inserting through unnest sends an explicit null, which skips the default.
|
|
86
|
+
const products = sections.flatMap((s) => s.products.map((p, i) => ({ ...p, recommended: p.recommended === true, section_id: s.id, sort_order: i + 1 })));
|
|
87
|
+
const column = (rows, key) => rows.map((row) => row[key] ?? null);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await client.query("begin");
|
|
91
|
+
if (replaceAll) await client.query("delete from public.menu_categories");
|
|
92
|
+
else await client.query("delete from public.menu_categories where slug = any($1::text[])", [column(categories, "slug")]);
|
|
93
|
+
|
|
94
|
+
const inserted = [
|
|
95
|
+
await client.query(
|
|
96
|
+
"insert into public.menu_categories (id, slug, name, sort_order) select * from unnest($1::uuid[], $2::text[], $3::text[], $4::int[])",
|
|
97
|
+
["id", "slug", "name", "sort_order"].map((key) => column(categories, key)),
|
|
98
|
+
),
|
|
99
|
+
await client.query(
|
|
100
|
+
"insert into public.menu_sections (id, category_id, name, sort_order) select * from unnest($1::uuid[], $2::uuid[], $3::text[], $4::int[])",
|
|
101
|
+
["id", "category_id", "name", "sort_order"].map((key) => column(sections, key)),
|
|
102
|
+
),
|
|
103
|
+
await client.query(
|
|
104
|
+
`insert into public.menu_products (section_id, name, description, price, currency, image_url, recommended, sort_order)
|
|
105
|
+
select * from unnest($1::uuid[], $2::text[], $3::text[], $4::numeric[], $5::text[], $6::text[], $7::boolean[], $8::int[])`,
|
|
106
|
+
["section_id", "name", "description", "price", "currency", "image_url", "recommended", "sort_order"].map((key) => column(products, key)),
|
|
107
|
+
),
|
|
108
|
+
];
|
|
109
|
+
const expected = [categories.length, sections.length, products.length];
|
|
110
|
+
if (inserted.some((result, i) => result.rowCount !== expected[i])) {
|
|
111
|
+
throw new Error("The database did not insert every row; nothing was changed.");
|
|
112
|
+
}
|
|
113
|
+
await client.query("commit");
|
|
114
|
+
return { categories: categories.length, sections: sections.length, products: products.length };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await client.query("rollback").catch(() => {});
|
|
117
|
+
throw explain(error);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The import itself, with no command line in it: check a menu, optionally save
|
|
2
|
+
// it as JSON, compare it with the database and replace it. Silent unless given
|
|
3
|
+
// a `log`; failures are thrown, never turned into an exit code.
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolveEnv } from "../../lib/env.mjs";
|
|
7
|
+
import { optionError, TablefactsError } from "../../lib/errors.mjs";
|
|
8
|
+
import { normalizeLog } from "../../lib/log.mjs";
|
|
9
|
+
import { projectRoot, resolveIn } from "../../lib/project.mjs";
|
|
10
|
+
import { connect, inspect, replaceMenu } from "./db.mjs";
|
|
11
|
+
import { countMenu, validateMenu } from "./menu.mjs";
|
|
12
|
+
|
|
13
|
+
/** Things the site needs that this menu does not give it, read from the template's own files. */
|
|
14
|
+
export async function templateHints(menu, { projectDir } = {}) {
|
|
15
|
+
const hints = [];
|
|
16
|
+
const content = join(projectRoot(projectDir), "frontend/src/content");
|
|
17
|
+
// A file that is missing means the template was reorganised: skip its hint rather than guess.
|
|
18
|
+
const read = (file) => readFile(join(content, file), "utf8").catch(() => null);
|
|
19
|
+
const [siteTs, qrTs, dictionary] = await Promise.all([read("site.ts"), read("qr.ts"), read("dictionary.ts")]);
|
|
20
|
+
|
|
21
|
+
const signatureBlock = siteTs?.match(/signatureMenu\s*=\s*\{([^}]*)\}/)?.[1] ?? "";
|
|
22
|
+
const signatureCategory = signatureBlock.match(/category:\s*"([^"]+)"/)?.[1];
|
|
23
|
+
const signature = signatureBlock.match(/section:\s*"([^"]+)"/)?.[1];
|
|
24
|
+
if (signature && !menu.find((c) => c.slug === signatureCategory)?.sections.some((s) => s.name === signature)) {
|
|
25
|
+
hints.push(`The home page cocktail list reads the "${signature}" section of the "${signatureCategory}" category (signatureMenu in content/site.ts). This menu has none, so that list will be empty.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (siteTs) {
|
|
29
|
+
const allowed = new Set([...(siteTs.match(/menuImageHosts[^=]*=\s*\[([^\]]*)\]/)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((m) => m[1]));
|
|
30
|
+
const hosts = new Set();
|
|
31
|
+
for (const c of menu) for (const s of c.sections) for (const p of s.products ?? []) {
|
|
32
|
+
try { if (p.image_url) hosts.add(new URL(p.image_url).hostname); } catch { /* reported by the import checks */ }
|
|
33
|
+
}
|
|
34
|
+
const missing = [...hosts].filter((host) => !allowed.has(host) && !host.endsWith(".supabase.co"));
|
|
35
|
+
if (missing.length) hints.push(`Photos load from ${missing.join(", ")}, which is not in menuImageHosts (content/site.ts): next/image will refuse them until it is.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (qrTs) {
|
|
39
|
+
const known = new Set([...qrTs.matchAll(/^\s*id:\s*"([^"]+)"/gm)].map((m) => m[1]));
|
|
40
|
+
const missing = menu.map((c) => c.slug).filter((slug) => !known.has(slug));
|
|
41
|
+
if (missing.length) hints.push(`Categories not in qrCategories (content/qr.ts): ${missing.join(", ")}. They render, but are not prerendered or in the sitemap and have no index photo; their names and blurbs also need keys in both dictionaries (menu.categories, qr.categoryBlurbs).`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const blocks = [...(dictionary ?? "").matchAll(/^\s*sections:\s*\{([^}]*)\}/gm)].map((m) => m[1]);
|
|
45
|
+
if (blocks.length) {
|
|
46
|
+
const keysOf = (block) => new Set([...block.matchAll(/^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*:/gm)].map((m) => m[1] ?? m[2]));
|
|
47
|
+
const mapped = blocks.map(keysOf);
|
|
48
|
+
const unmapped = [...new Set(menu.flatMap((c) => c.sections.map((s) => s.name)))].filter((name) => mapped.some((keys) => !keys.has(name)));
|
|
49
|
+
if (unmapped.length) hints.push(`${unmapped.length} sections have no display name in both languages (menu.sections in dictionary.ts) and will show as stored, in capitals: ${unmapped.join(", ")}.`);
|
|
50
|
+
}
|
|
51
|
+
return hints;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Checks `menu` (the shape of lib/menu.mjs) and writes it to the database.
|
|
56
|
+
* `notes` are what the source wants the user to know; `title` heads the log.
|
|
57
|
+
* Resolves to `{ totals, notes, written, dryRun, database }`, `database` being
|
|
58
|
+
* `{ label, current }` once it was inspected and null when it was not reached.
|
|
59
|
+
* @param {import('../../lib/types.mjs').ImportMenuOptions} options
|
|
60
|
+
* @returns {Promise<import('../../lib/types.mjs').ImportResult>}
|
|
61
|
+
*/
|
|
62
|
+
export async function importMenu({
|
|
63
|
+
menu,
|
|
64
|
+
notes = [],
|
|
65
|
+
title = "",
|
|
66
|
+
dryRun = false,
|
|
67
|
+
json,
|
|
68
|
+
replaceAll = false,
|
|
69
|
+
force = false,
|
|
70
|
+
databaseUrl,
|
|
71
|
+
env,
|
|
72
|
+
projectDir,
|
|
73
|
+
log: logOption,
|
|
74
|
+
} = {}) {
|
|
75
|
+
const log = normalizeLog(logOption);
|
|
76
|
+
databaseUrl ??= resolveEnv(env).SUPABASE_DB_URL;
|
|
77
|
+
let client;
|
|
78
|
+
try {
|
|
79
|
+
validateMenu(menu);
|
|
80
|
+
const totals = countMenu(menu);
|
|
81
|
+
|
|
82
|
+
log(`${title}\n`);
|
|
83
|
+
for (const category of menu) {
|
|
84
|
+
const n = countMenu([category]);
|
|
85
|
+
log(` ${category.name} (${category.slug}): ${n.sections} sections, ${n.products} products`);
|
|
86
|
+
}
|
|
87
|
+
log(` Total: ${totals.categories} categories, ${totals.sections} sections, ${totals.products} products (${totals.withImage} with a photo)`);
|
|
88
|
+
if (json) {
|
|
89
|
+
await writeFile(resolveIn(projectDir, json), JSON.stringify(menu, null, 2) + "\n");
|
|
90
|
+
log(`\nSaved ${json}`);
|
|
91
|
+
}
|
|
92
|
+
const allNotes = [...notes, ...(await templateHints(menu, { projectDir }))];
|
|
93
|
+
for (const line of allNotes) log(`\n- ${line}`, "warn");
|
|
94
|
+
|
|
95
|
+
const result = { totals, notes: allNotes, written: false, dryRun: !!dryRun, database: null };
|
|
96
|
+
if (!totals.products) throw new TablefactsError("The source returned no products, so there is nothing to import.", "EFAILED");
|
|
97
|
+
if (dryRun && !databaseUrl) {
|
|
98
|
+
log("\nDry run: SUPABASE_DB_URL is not set, so the database was not checked. Nothing was written.", "warn");
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const scope = { replaceAll: !!replaceAll };
|
|
103
|
+
let label;
|
|
104
|
+
({ client, label } = await connect(databaseUrl, { env }));
|
|
105
|
+
const current = await inspect(client, menu, scope);
|
|
106
|
+
result.database = { label, current };
|
|
107
|
+
log(`\nDatabase ${label}`);
|
|
108
|
+
log(` ${scope.replaceAll ? "Replaces the whole menu" : `Replaces the categories this import writes`}: ${current.categories} categories and ${current.products} products now, ${totals.categories} and ${totals.products} after.`);
|
|
109
|
+
if (current.kept.length) log(` Left untouched (not part of this import): ${current.kept.join(", ")}. Use \`replaceAll\` to remove them.`, "warn");
|
|
110
|
+
|
|
111
|
+
if (current.products > 0 && totals.products < current.products / 2 && !force) {
|
|
112
|
+
throw optionError("force", `This import has ${totals.products} products and would replace ${current.products}. That looks like a broken extraction, so nothing was written. Use \`force\` if it is intended.`, "EFAILED");
|
|
113
|
+
}
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
log("\nDry run: nothing was written.");
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
const done = await replaceMenu(client, menu, scope);
|
|
119
|
+
result.written = true;
|
|
120
|
+
log(`\nDone: wrote ${done.categories} categories, ${done.sections} sections and ${done.products} products.`);
|
|
121
|
+
log("The site shows them within the hour (the menu pages revalidate hourly).");
|
|
122
|
+
return result;
|
|
123
|
+
} finally {
|
|
124
|
+
await client?.end().catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// What every menu source (src/menu/<source>/) produces, and the helpers they
|
|
2
|
+
// share. A source turns a restaurant's menu on some website into this shape;
|
|
3
|
+
// lib/run.mjs then checks it and writes it to Supabase.
|
|
4
|
+
//
|
|
5
|
+
// [{ slug, name, sections: [{ name, products: [
|
|
6
|
+
// { name, description, price, currency, image_url, recommended } ] }] }]
|
|
7
|
+
//
|
|
8
|
+
// Array order is display order (it becomes sort_order). The conventions are
|
|
9
|
+
// the ones in frontend/src/content/menu.ts: a category's `slug` is the key the
|
|
10
|
+
// dictionaries use, and section names are UPPERCASE strings that
|
|
11
|
+
// `menu.sections` in dictionary.ts maps to display names.
|
|
12
|
+
import { TablefactsError } from "../../lib/errors.mjs";
|
|
13
|
+
import { fold, slugify as slug } from "../../lib/text.mjs";
|
|
14
|
+
|
|
15
|
+
const squash = (text) => String(text ?? "").replace(/\s+/g, " ").trim();
|
|
16
|
+
|
|
17
|
+
/** Collapses runs of whitespace (Cluvi labels carry stray spaces). */
|
|
18
|
+
export const cleanText = squash;
|
|
19
|
+
|
|
20
|
+
/** Lowercase, accent-free key, to compare labels typed by different people. */
|
|
21
|
+
export const matchKey = (text) => fold(squash(text));
|
|
22
|
+
|
|
23
|
+
export const slugify = (text) => slug(squash(text));
|
|
24
|
+
|
|
25
|
+
export const sectionName = (text) => squash(text).toLocaleUpperCase("es");
|
|
26
|
+
|
|
27
|
+
const entities = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
28
|
+
|
|
29
|
+
function decodeEntity(match, body) {
|
|
30
|
+
if (body[0] !== "#") return entities[body.toLowerCase()] ?? match;
|
|
31
|
+
const code = body[1].toLowerCase() === "x" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
32
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Rich-text descriptions (<p>, <br>, <span style>) to plain text, one line per paragraph. */
|
|
36
|
+
export function htmlToText(html) {
|
|
37
|
+
if (!html) return null;
|
|
38
|
+
const text = String(html)
|
|
39
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
40
|
+
.replace(/<\/(p|div|li|h[1-6])\s*>/gi, "\n")
|
|
41
|
+
.replace(/<[^>]*>/g, "")
|
|
42
|
+
.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, decodeEntity)
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map(squash)
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join("\n");
|
|
47
|
+
return text || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const currencies = new Set(Intl.supportedValuesOf("currency"));
|
|
51
|
+
|
|
52
|
+
/** An invalid code would make Intl.NumberFormat throw in the site's formatPrice. */
|
|
53
|
+
export const isCurrency = (code) => currencies.has(String(code ?? "").toUpperCase());
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Totals of a menu.
|
|
57
|
+
* @param {import('../../lib/types.mjs').Menu} menu
|
|
58
|
+
* @returns {import('../../lib/types.mjs').MenuTotals}
|
|
59
|
+
*/
|
|
60
|
+
export function countMenu(menu) {
|
|
61
|
+
const sections = menu.flatMap((c) => c.sections);
|
|
62
|
+
const products = sections.flatMap((s) => s.products);
|
|
63
|
+
return {
|
|
64
|
+
categories: menu.length,
|
|
65
|
+
sections: sections.length,
|
|
66
|
+
products: products.length,
|
|
67
|
+
withImage: products.filter((p) => p.image_url).length,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Throws a TablefactsError (EFAILED) on anything the database or the site could not take, naming the culprit.
|
|
73
|
+
* @param {import('../../lib/types.mjs').Menu} menu
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
export function validateMenu(menu) {
|
|
77
|
+
const problems = [];
|
|
78
|
+
const slugs = new Set();
|
|
79
|
+
for (const category of menu) {
|
|
80
|
+
if (!category.slug || !category.name) problems.push(`a category has no slug or name (${JSON.stringify(category.name)})`);
|
|
81
|
+
if (slugs.has(category.slug)) problems.push(`category slug "${category.slug}" appears twice`);
|
|
82
|
+
slugs.add(category.slug);
|
|
83
|
+
for (const section of category.sections) {
|
|
84
|
+
if (!section.name) problems.push(`a section of "${category.slug}" has no name`);
|
|
85
|
+
for (const product of section.products) {
|
|
86
|
+
const where = `"${product.name}" in ${category.slug} / ${section.name}`;
|
|
87
|
+
if (!product.name) problems.push(`a product of ${category.slug} / ${section.name} has no name`);
|
|
88
|
+
if (!Number.isFinite(product.price) || product.price < 0) problems.push(`${where}: invalid price ${product.price}`);
|
|
89
|
+
if (!isCurrency(product.currency)) problems.push(`${where}: invalid currency "${product.currency}"`);
|
|
90
|
+
if (product.image_url && !/^https:\/\//.test(product.image_url)) problems.push(`${where}: image_url is not an https URL`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (problems.length) throw new TablefactsError(`The extracted menu is not valid:\n - ${problems.slice(0, 10).join("\n - ")}`, "EFAILED");
|
|
95
|
+
}
|