woo2emdash 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/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-09-10)
4
+
5
+ First working version.
6
+
7
+ - `analyze` reports what a WooCommerce store contains, with a language breakdown for multilingual stores.
8
+ - `schema show` and `schema apply` create the documented target schema: `products` and `product_variations` collections, `product_category`, `product_tag` and `product_brand` taxonomies, and one taxonomy per WooCommerce global attribute.
9
+ - `import` copies products, variations, taxonomy terms and images, including images inside descriptions. Entries are matched by WooCommerce ID, so re-runs update in place. Options: `--lang`, `--locale`, `--since`, `--limit`, `--dry-run`, `--skip-media`, `--skip-variations`.
10
+ - `redirects` writes a 301 map from the old product URLs to the EmDash ones in the EmDash seed format.
11
+ - Multilingual stores: the language comes from WC Multilang 1.3.0's `wcml_language` field when present (permalink prefix otherwise), `--lang` filters server-side, and `--locale` stores entries and terms under an EmDash locale and links language copies as translations through `wcml_translations`.
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rafael Minuesa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # woo2emdash
2
+
3
+ Command line tool that copies a WooCommerce catalog into [EmDash CMS](https://emdashcms.com) collections. It reads the store through the WooCommerce REST API with a read-only key and writes through EmDash's documented REST API, so it works against EmDash on Cloudflare or on Node.
4
+
5
+ Status: pre-release. `analyze`, `schema apply`, `import` and `redirects` work for products, variations, taxonomy terms and images, including images inside descriptions. Customers, orders and reviews are not in scope yet. The target schema is documented in [docs/target-schema.md](docs/target-schema.md).
6
+
7
+ ## Why
8
+
9
+ EmDash's built-in WordPress importer keeps titles and body text but drops every product field, and WooCommerce orders are not even in a WordPress export anymore. This tool carries the catalog across with prices, stock, variations, attributes, categories, tags, brands and images intact, into a schema other EmDash tools can rely on.
10
+
11
+ ## Requirements
12
+
13
+ - Node.js 22.16 or later
14
+ - A WooCommerce REST API key with Read permission (WooCommerce, Settings, Advanced, REST API)
15
+ - An EmDash site. On localhost nothing else is needed. For a remote site, an EmDash API token.
16
+
17
+ ## Usage
18
+
19
+ ```sh
20
+ export WOO_URL=https://shop.example.com
21
+ export WOO_CONSUMER_KEY=ck_...
22
+ export WOO_CONSUMER_SECRET=cs_...
23
+ export EMDASH_URL=http://127.0.0.1:4321
24
+
25
+ npx woo2emdash analyze # what the store contains
26
+ npx woo2emdash analyze --lang en # multilingual stores, one language at a time
27
+ npx woo2emdash schema show # the target schema
28
+ npx woo2emdash schema apply --dry-run # what would be created in EmDash
29
+ npx woo2emdash schema apply # create collections, fields and taxonomies (safe to re-run)
30
+ npx woo2emdash import --dry-run --lang en --limit 5 # rehearse on five products, nothing written
31
+ npx woo2emdash import --lang en # the real thing, safe to re-run
32
+ ```
33
+
34
+ Re-running `import` updates entries in place. For large stores, `--since 2026-09-01` limits a re-run to products WooCommerce modified after that date. Every entry is matched by its WooCommerce ID, images are deduplicated by EmDash, and terms are reused by slug. Published products stay published, everything else is a draft.
35
+
36
+ After the import, `redirects` writes a 301 map from every published product's old WooCommerce URL to its EmDash URL, in the EmDash seed `redirects` format. Pass `--pattern /shop/{slug}` if your product pages do not live under the collection's URL pattern, and `--out redirects.json` to write a file.
37
+
38
+ ```sh
39
+ npx woo2emdash redirects --out redirects.json
40
+ ```
41
+
42
+ Multilingual stores (WPML, Polylang, WC Multilang) hold one product per language. Import one language per run with `--lang`, which matches the language prefix in the product's permalink. Terms are created only for the products actually imported, so the other languages' categories and tags stay out. If the EmDash site has locales configured, add `--locale fr` to store that run's entries and terms under the matching locale. With [WC Multilang](https://github.com/ProWoos-Devs/wc-multilang) 1.3.0 or later on the store, the language is read from the product itself, filtering happens server-side, and each language copy is linked to the already imported ones as an EmDash translation. Import the default language first, then the others.
43
+
44
+ Run `woo2emdash --help` for every flag.
45
+
46
+ ## Development
47
+
48
+ ```sh
49
+ npm install
50
+ npm run typecheck
51
+ npm run dev -- analyze # runs the TypeScript source directly
52
+ npm run build # emits dist/
53
+ ```
54
+
55
+ ## License
56
+
57
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ import { HELP, parseCli } from "./config.js";
3
+ import { log } from "./log.js";
4
+ import { analyze } from "./commands/analyze.js";
5
+ import { schemaApply, schemaShow } from "./commands/schema.js";
6
+ import { runImport } from "./commands/import.js";
7
+ import { redirects } from "./commands/redirects.js";
8
+ async function main() {
9
+ const cli = parseCli(process.argv.slice(2));
10
+ const [cmd, sub] = cli.command;
11
+ if (cli.flags.help || !cmd) {
12
+ process.stdout.write(HELP);
13
+ return cmd ? 0 : 1;
14
+ }
15
+ switch (`${cmd} ${sub ?? ""}`.trim()) {
16
+ case "analyze":
17
+ await analyze(cli);
18
+ return 0;
19
+ case "schema show":
20
+ schemaShow(cli);
21
+ return 0;
22
+ case "schema apply":
23
+ await schemaApply(cli);
24
+ return 0;
25
+ case "import":
26
+ return (await runImport(cli)) ? 0 : 1;
27
+ case "redirects":
28
+ await redirects(cli);
29
+ return 0;
30
+ default:
31
+ log.error(`Unknown command: ${cli.command.join(" ")}`);
32
+ process.stdout.write(HELP);
33
+ return 1;
34
+ }
35
+ }
36
+ main()
37
+ .then((code) => process.exit(code))
38
+ .catch((e) => {
39
+ log.error(e instanceof Error ? e.message : String(e));
40
+ process.exit(1);
41
+ });
@@ -0,0 +1,117 @@
1
+ import { log } from "../log.js";
2
+ import { WooClient } from "../woo/client.js";
3
+ import { productLanguage } from "../import/convert.js";
4
+ const bump = (rec, key) => {
5
+ rec[key] = (rec[key] ?? 0) + 1;
6
+ };
7
+ export async function analyze(cli) {
8
+ const woo = new WooClient(cli.woo());
9
+ const wooCfg = cli.woo();
10
+ log.step(`Analyzing ${wooCfg.url}`);
11
+ const settings = [
12
+ ...((await woo.optional("settings/general")) ?? []),
13
+ ...((await woo.optional("settings/products")) ?? []),
14
+ ];
15
+ const setting = (id) => {
16
+ const v = settings.find((s) => s.id === id)?.value;
17
+ return typeof v === "string" ? v : null;
18
+ };
19
+ const store = {
20
+ url: wooCfg.url,
21
+ currency: setting("woocommerce_currency"),
22
+ weightUnit: setting("woocommerce_weight_unit"),
23
+ dimensionUnit: setting("woocommerce_dimension_unit"),
24
+ };
25
+ const warnings = [];
26
+ if (!store.currency)
27
+ warnings.push("Could not read woocommerce_currency from settings/general; the key may lack settings access.");
28
+ const products = {
29
+ counted: 0,
30
+ total: 0,
31
+ byType: {},
32
+ byStatus: {},
33
+ byLanguagePrefix: {},
34
+ withVariations: 0,
35
+ variationsTotal: 0,
36
+ withAttributes: 0,
37
+ withGallery: 0,
38
+ imagesTotal: 0,
39
+ onSale: 0,
40
+ withSku: 0,
41
+ };
42
+ products.total = (await woo.get("products", { per_page: 1, status: "any" })).total;
43
+ const params = { status: "any", _fields: "id,type,status,permalink,variations,attributes,images,sale_price,sku,wcml_language" };
44
+ if (cli.flags.lang)
45
+ params.lang = cli.flags.lang; // honored by WC Multilang 1.3.0+, ignored elsewhere
46
+ let languageField = 0;
47
+ for await (const p of woo.all("products", params, cli.flags.limit)) {
48
+ if (typeof p.wcml_language === "string")
49
+ languageField += 1;
50
+ const prefix = productLanguage(p);
51
+ if (cli.flags.lang && prefix !== cli.flags.lang.toLowerCase())
52
+ continue;
53
+ products.counted += 1;
54
+ bump(products.byType, p.type);
55
+ bump(products.byStatus, p.status);
56
+ bump(products.byLanguagePrefix, prefix);
57
+ if (p.variations.length > 0) {
58
+ products.withVariations += 1;
59
+ products.variationsTotal += p.variations.length;
60
+ }
61
+ if (p.attributes.length > 0)
62
+ products.withAttributes += 1;
63
+ if (p.images.length > 1)
64
+ products.withGallery += 1;
65
+ products.imagesTotal += p.images.length;
66
+ if (p.sale_price !== "")
67
+ products.onSale += 1;
68
+ if (p.sku !== "")
69
+ products.withSku += 1;
70
+ }
71
+ const categories = (await woo.get("products/categories", { per_page: 1 })).total;
72
+ const tags = (await woo.get("products/tags", { per_page: 1 })).total;
73
+ const brandsRes = await woo.optional("products/brands", { per_page: 1 });
74
+ const brands = brandsRes === null ? null : (await woo.get("products/brands", { per_page: 1 })).total;
75
+ if (brands === null)
76
+ warnings.push("No products/brands endpoint on this store (WooCommerce before 9.6); the product_brand taxonomy will stay empty.");
77
+ const globalAttributes = [];
78
+ for await (const a of woo.all("products/attributes"))
79
+ globalAttributes.push({ slug: a.slug, name: a.name });
80
+ if (languageField === 0 && Object.keys(products.byLanguagePrefix).some((k) => k !== "(none)")) {
81
+ warnings.push("Language is inferred from permalink prefixes. Stores running WC Multilang 1.3.0+ report it directly (wcml_language).");
82
+ }
83
+ const languages = Object.keys(products.byLanguagePrefix).filter((k) => k !== "(none)");
84
+ if (languages.length > 1 && !cli.flags.lang) {
85
+ warnings.push(`Products carry ${languages.length} language prefixes (${languages.join(", ")}). Each language is a separate WooCommerce product, so import one language at a time with --lang, or expect duplicates.`);
86
+ }
87
+ for (const t of ["grouped", "simple", "variable", "external"]) {
88
+ if (!products.byType[t])
89
+ warnings.push(`No ${t} products in this store, that type stays untested here.`);
90
+ }
91
+ const report = { store, products, taxonomies: { categories, tags, brands, globalAttributes }, warnings };
92
+ if (cli.flags.json) {
93
+ console.log(JSON.stringify(report, null, 2));
94
+ return;
95
+ }
96
+ const fmt = (rec) => Object.entries(rec)
97
+ .sort((a, b) => b[1] - a[1])
98
+ .map(([k, v]) => `${k} ${v}`)
99
+ .join(", ");
100
+ console.log(`Store ${store.url}`);
101
+ console.log(`Currency ${store.currency ?? "?"} weight ${store.weightUnit ?? "?"} dimensions ${store.dimensionUnit ?? "?"}`);
102
+ console.log(`Products ${products.counted} counted of ${products.total} total${cli.flags.lang ? ` (language ${cli.flags.lang})` : ""}`);
103
+ console.log(` by type ${fmt(products.byType)}`);
104
+ console.log(` by status ${fmt(products.byStatus)}`);
105
+ console.log(` by language ${fmt(products.byLanguagePrefix)}`);
106
+ console.log(` variations ${products.variationsTotal} across ${products.withVariations} products`);
107
+ console.log(` attributes ${products.withAttributes} products carry attributes`);
108
+ console.log(` images ${products.imagesTotal} total, ${products.withGallery} products with a gallery`);
109
+ console.log(` on sale ${products.onSale}`);
110
+ console.log(` with SKU ${products.withSku}`);
111
+ console.log(`Categories ${categories}`);
112
+ console.log(`Tags ${tags}`);
113
+ console.log(`Brands ${brands ?? "endpoint missing"}`);
114
+ console.log(`Global attrs ${globalAttributes.length}${globalAttributes.length ? " (" + globalAttributes.map((a) => a.slug).join(", ") + ")" : ""}`);
115
+ for (const w of warnings)
116
+ log.warn(w);
117
+ }
@@ -0,0 +1,211 @@
1
+ import { EmDashClient } from "../emdash/client.js";
2
+ import { entrySlug, gmtDate, productData, productLanguage, variationData } from "../import/convert.js";
3
+ import { EntryWriter } from "../import/entries.js";
4
+ import { relinkImages } from "../import/images.js";
5
+ import { MediaImporter } from "../import/media.js";
6
+ import { TermSync } from "../import/terms.js";
7
+ import { log } from "../log.js";
8
+ import { PRODUCTS, VARIATIONS } from "../schema/catalog.js";
9
+ import { WooClient } from "../woo/client.js";
10
+ async function storeUnits(woo) {
11
+ const settings = [
12
+ ...((await woo.optional("settings/general")) ?? []),
13
+ ...((await woo.optional("settings/products")) ?? []),
14
+ ];
15
+ const get = (id) => {
16
+ const v = settings.find((s) => s.id === id)?.value;
17
+ return typeof v === "string" && v !== "" ? v : undefined;
18
+ };
19
+ return {
20
+ currency: get("woocommerce_currency"),
21
+ weightUnit: get("woocommerce_weight_unit"),
22
+ dimensionUnit: get("woocommerce_dimension_unit"),
23
+ };
24
+ }
25
+ async function termAssignments(p, terms) {
26
+ const out = {};
27
+ const add = async (taxonomy, slugs) => {
28
+ const list = (await Promise.all(slugs)).filter((s) => Boolean(s));
29
+ if (list.length > 0)
30
+ out[taxonomy] = list;
31
+ };
32
+ await add("product_category", p.categories.map((c) => terms.ensureById("product_category", c.id)));
33
+ await add("product_tag", p.tags.map((t) => terms.ensureById("product_tag", t.id)));
34
+ if (p.brands)
35
+ await add("product_brand", p.brands.map((b) => terms.ensureById("product_brand", b.id)));
36
+ for (const a of p.attributes) {
37
+ if (a.id === 0 || !a.slug)
38
+ continue;
39
+ const taxonomy = terms.attributeTaxonomy(a.slug);
40
+ if (!taxonomy)
41
+ continue;
42
+ await add(taxonomy, a.options.map((o) => terms.ensureByName(taxonomy, o)));
43
+ }
44
+ return out;
45
+ }
46
+ async function variationTerms(v, terms) {
47
+ const out = {};
48
+ for (const a of v.attributes) {
49
+ if (a.id === 0 || !a.slug || !a.option)
50
+ continue;
51
+ const taxonomy = terms.attributeTaxonomy(a.slug);
52
+ const slug = taxonomy ? await terms.ensureByName(taxonomy, a.option) : undefined;
53
+ if (taxonomy && slug)
54
+ out[taxonomy] = [slug];
55
+ }
56
+ return out;
57
+ }
58
+ export async function runImport(cli) {
59
+ const dryRun = cli.flags.dryRun;
60
+ const wooCfg = cli.woo();
61
+ const emdashCfg = cli.emdash();
62
+ const woo = new WooClient(wooCfg);
63
+ const emdash = new EmDashClient(emdashCfg);
64
+ const media = new MediaImporter(emdash, dryRun || cli.flags.skipMedia);
65
+ const locale = cli.flags.locale;
66
+ const writer = new EntryWriter(emdash, dryRun, locale);
67
+ const terms = new TermSync(woo, emdash, dryRun, locale);
68
+ let relinkedImages = 0;
69
+ let linkedTranslations = 0;
70
+ const errors = [];
71
+ log.step(`${dryRun ? "Dry run: " : ""}importing ${wooCfg.url} into ${emdashCfg.url}${cli.flags.lang ? ` (language ${cli.flags.lang})` : ""}`);
72
+ const collections = new Set((await emdash.collections()).map((c) => c.slug));
73
+ if (!collections.has(PRODUCTS) || !collections.has(VARIATIONS)) {
74
+ log.error(`Collections ${PRODUCTS} and ${VARIATIONS} are missing. Run "schema apply" first.`);
75
+ return false;
76
+ }
77
+ const units = await storeUnits(woo);
78
+ log.info(`store currency ${units.currency ?? "?"}, weight ${units.weightUnit ?? "?"}, dimensions ${units.dimensionUnit ?? "?"}`);
79
+ log.step("Terms");
80
+ await terms.load();
81
+ log.step("Products");
82
+ const pending = [];
83
+ let seen = 0;
84
+ const productParams = { status: "any" };
85
+ if (cli.flags.lang)
86
+ productParams.lang = cli.flags.lang; // server-side filter with WC Multilang 1.3.0+, ignored elsewhere
87
+ if (cli.flags.since) {
88
+ productParams.modified_after = new Date(cli.flags.since).toISOString();
89
+ log.info(`only products modified after ${productParams.modified_after}`);
90
+ }
91
+ for await (const p of woo.all("products", productParams, undefined)) {
92
+ if (cli.flags.lang && productLanguage(p) !== cli.flags.lang.toLowerCase())
93
+ continue;
94
+ if (cli.flags.limit !== undefined && seen >= cli.flags.limit)
95
+ break;
96
+ seen += 1;
97
+ try {
98
+ const [first, ...rest] = p.images;
99
+ const featured = await media.image(first);
100
+ const gallery = (await Promise.all(rest.map((img) => media.image(img)))).filter((m) => Boolean(m));
101
+ const publish = p.status === "publish";
102
+ const data = productData(p, units, terms.attributeTaxonomy, featured, gallery);
103
+ if (Array.isArray(data.content) && data.content.length > 0) {
104
+ const r = await relinkImages(data.content, media);
105
+ data.content = r.blocks;
106
+ relinkedImages += r.relinked;
107
+ }
108
+ let translationOf;
109
+ if (locale && p.wcml_translations) {
110
+ for (const [lang, wooId] of Object.entries(p.wcml_translations)) {
111
+ if (wooId === p.id || lang.toLowerCase() === locale.toLowerCase())
112
+ continue;
113
+ const sibling = await writer.findAnyLocale(PRODUCTS, wooId);
114
+ if (sibling) {
115
+ translationOf = sibling.id;
116
+ linkedTranslations += 1;
117
+ break;
118
+ }
119
+ }
120
+ }
121
+ const entry = await writer.upsert({
122
+ collection: PRODUCTS,
123
+ wooId: p.id,
124
+ slug: entrySlug(p.slug, p.id),
125
+ data,
126
+ publish,
127
+ createdAt: gmtDate(p.date_created_gmt),
128
+ taxonomies: await termAssignments(p, terms),
129
+ translationOf,
130
+ });
131
+ pending.push({ product: p, entryId: entry.id, publish });
132
+ log.info(`${p.type.padEnd(8)} ${p.id} ${p.name}${cli.flags.skipMedia ? "" : ` (${p.images.length} images)`}`);
133
+ }
134
+ catch (e) {
135
+ const msg = `product ${p.id} ${p.name}: ${e instanceof Error ? e.message : String(e)}`;
136
+ errors.push(msg);
137
+ log.error(msg);
138
+ }
139
+ }
140
+ let variationsDone = 0;
141
+ if (!cli.flags.skipVariations) {
142
+ log.step("Variations");
143
+ for (const { product, entryId, publish } of pending) {
144
+ if (product.variations.length === 0)
145
+ continue;
146
+ for await (const v of woo.all(`products/${product.id}/variations`, { status: "any" })) {
147
+ try {
148
+ const featured = await media.image(v.image ?? undefined);
149
+ const data = variationData(v, product.name, entryId, units, terms.attributeTaxonomy, featured);
150
+ if (Array.isArray(data.content) && data.content.length > 0) {
151
+ const r = await relinkImages(data.content, media);
152
+ data.content = r.blocks;
153
+ relinkedImages += r.relinked;
154
+ }
155
+ await writer.upsert({
156
+ collection: VARIATIONS,
157
+ wooId: v.id,
158
+ slug: `${entrySlug(product.slug, product.id)}-${v.id}`,
159
+ data,
160
+ publish: publish && v.status === "publish",
161
+ createdAt: gmtDate(v.date_created_gmt),
162
+ taxonomies: await variationTerms(v, terms),
163
+ });
164
+ variationsDone += 1;
165
+ }
166
+ catch (e) {
167
+ const msg = `variation ${v.id} of ${product.name}: ${e instanceof Error ? e.message : String(e)}`;
168
+ errors.push(msg);
169
+ log.error(msg);
170
+ }
171
+ }
172
+ log.info(`${product.variations.length} variations of ${product.name}`);
173
+ }
174
+ }
175
+ log.step("Cross references");
176
+ let linked = 0;
177
+ const resolve = (ids) => ids.map((id) => writer.entryFor(PRODUCTS, id)?.id).filter((id) => Boolean(id));
178
+ for (const { product, entryId, publish } of pending) {
179
+ const data = {};
180
+ const up = resolve(product.upsell_ids);
181
+ const cross = resolve(product.cross_sell_ids);
182
+ const grouped = resolve(product.grouped_products);
183
+ if (up.length)
184
+ data.upsells = up;
185
+ if (cross.length)
186
+ data.cross_sells = cross;
187
+ if (grouped.length)
188
+ data.grouped_products = grouped;
189
+ if (Object.keys(data).length === 0)
190
+ continue;
191
+ try {
192
+ await writer.patch(PRODUCTS, entryId, data, publish);
193
+ linked += 1;
194
+ }
195
+ catch (e) {
196
+ const msg = `references of ${product.name}: ${e instanceof Error ? e.message : String(e)}`;
197
+ errors.push(msg);
198
+ log.error(msg);
199
+ }
200
+ }
201
+ log.step("Summary");
202
+ log.ok(`${dryRun ? "Would write" : "Wrote"} ${pending.length} products (${writer.created} created, ${writer.updated} updated in total), ${variationsDone} variations, ${terms.created} new terms, ${linked} products with cross references${locale ? `, ${linkedTranslations} linked as translations` : ""}.`);
203
+ if (!cli.flags.skipMedia && !dryRun)
204
+ log.ok(`Images: ${media.uploaded} uploaded, ${media.reused} already present, ${media.failed} failed, ${relinkedImages} inside descriptions repointed.`);
205
+ if (errors.length > 0) {
206
+ log.error(`${errors.length} errors:`);
207
+ for (const e of errors)
208
+ log.error(` ${e}`);
209
+ }
210
+ return errors.length === 0;
211
+ }
@@ -0,0 +1,63 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { EmDashClient } from "../emdash/client.js";
3
+ import { log } from "../log.js";
4
+ import { PRODUCTS } from "../schema/catalog.js";
5
+ /**
6
+ * Builds a redirect map from the WooCommerce product URLs (kept in
7
+ * `woo_permalink`) to the EmDash product URLs. Output uses the EmDash seed
8
+ * file `redirects` format, so it can be pasted into a seed or converted to
9
+ * Cloudflare or Astro redirects.
10
+ */
11
+ export async function redirects(cli) {
12
+ const emdashCfg = cli.emdash();
13
+ const client = new EmDashClient(emdashCfg);
14
+ const locale = cli.flags.locale;
15
+ let pattern = cli.flags.pattern;
16
+ if (!pattern) {
17
+ const col = await client.collectionInfo(PRODUCTS);
18
+ pattern = col.urlPattern ?? `/${PRODUCTS}/{slug}`;
19
+ if (!col.urlPattern)
20
+ log.warn(`The ${PRODUCTS} collection has no URL pattern yet, assuming ${pattern}. Pass --pattern to override.`);
21
+ }
22
+ if (!pattern.includes("{slug}"))
23
+ throw new Error("--pattern must contain {slug}");
24
+ const out = [];
25
+ let skipped = 0;
26
+ let drafts = 0;
27
+ for await (const item of client.allEntries(PRODUCTS, locale)) {
28
+ // Drafts never had a public URL in WooCommerce (their permalink is a ?p= preview link).
29
+ if (item.status !== "published") {
30
+ drafts += 1;
31
+ continue;
32
+ }
33
+ const permalink = item.data.woo_permalink;
34
+ if (typeof permalink !== "string") {
35
+ skipped += 1;
36
+ continue;
37
+ }
38
+ let source;
39
+ try {
40
+ const u = new URL(permalink);
41
+ source = u.pathname + (u.search || "");
42
+ }
43
+ catch {
44
+ skipped += 1;
45
+ continue;
46
+ }
47
+ const destination = pattern.replace("{slug}", item.slug);
48
+ if (source === destination)
49
+ continue;
50
+ out.push({ source, destination, type: 301, groupName: "woocommerce" });
51
+ }
52
+ out.sort((a, b) => a.source.localeCompare(b.source));
53
+ const json = JSON.stringify({ redirects: out }, null, 2) + "\n";
54
+ const notes = [drafts ? `${drafts} drafts left out` : "", skipped ? `${skipped} entries without a WooCommerce URL skipped` : ""].filter(Boolean).join(", ");
55
+ if (cli.flags.out) {
56
+ await writeFile(cli.flags.out, json, "utf8");
57
+ log.ok(`Wrote ${out.length} redirects to ${cli.flags.out}${notes ? ` (${notes})` : ""}.`);
58
+ }
59
+ else {
60
+ process.stdout.write(json);
61
+ log.ok(`${out.length} redirects${notes ? ` (${notes})` : ""}.`);
62
+ }
63
+ }
@@ -0,0 +1,120 @@
1
+ import { EmDashClient } from "../emdash/client.js";
2
+ import { log } from "../log.js";
3
+ import { attributeTaxonomy, catalogCollections, catalogTaxonomies, } from "../schema/catalog.js";
4
+ import { WooClient } from "../woo/client.js";
5
+ export function schemaShow(cli) {
6
+ if (cli.flags.json) {
7
+ console.log(JSON.stringify({ collections: catalogCollections, taxonomies: catalogTaxonomies }, null, 2));
8
+ return;
9
+ }
10
+ for (const c of catalogCollections) {
11
+ console.log(`Collection ${c.slug} (${c.label}, routable ${c.routable}, seo ${c.hasSeo})`);
12
+ for (const f of c.fields) {
13
+ const flags = [f.required && "required", f.unique && "unique", f.indexed && "indexed"].filter(Boolean).join(" ");
14
+ console.log(` ${f.slug.padEnd(20)} ${f.type.padEnd(13)} ${flags.padEnd(24)} <- ${f.source}`);
15
+ }
16
+ }
17
+ for (const t of catalogTaxonomies) {
18
+ console.log(`Taxonomy ${t.name} (${t.hierarchical ? "hierarchical" : "flat"}, on ${t.collections.join(", ")}) <- ${t.source}`);
19
+ }
20
+ console.log("Taxonomy pa_<attribute> one flat taxonomy per WooCommerce global attribute, created by schema apply when a store is connected");
21
+ }
22
+ function fieldBody(f, sortOrder) {
23
+ const body = { slug: f.slug, label: f.label, type: f.type, sortOrder };
24
+ if (f.required !== undefined)
25
+ body.required = f.required;
26
+ if (f.unique !== undefined)
27
+ body.unique = f.unique;
28
+ if (f.indexed !== undefined)
29
+ body.indexed = f.indexed;
30
+ if (f.defaultValue !== undefined)
31
+ body.defaultValue = f.defaultValue;
32
+ if (f.validation !== undefined)
33
+ body.validation = f.validation;
34
+ if (f.options !== undefined)
35
+ body.options = f.options;
36
+ return body;
37
+ }
38
+ async function applyCollection(client, def, existing, dryRun) {
39
+ let created = 0;
40
+ if (!existing.has(def.slug)) {
41
+ log.info(`${dryRun ? "would create" : "creating"} collection ${def.slug}`);
42
+ if (!dryRun) {
43
+ await client.createCollection({
44
+ slug: def.slug,
45
+ label: def.label,
46
+ labelSingular: def.labelSingular,
47
+ description: def.description,
48
+ icon: def.icon,
49
+ supports: def.supports,
50
+ routable: def.routable,
51
+ hidden: def.hidden,
52
+ hasSeo: def.hasSeo,
53
+ source: "import:woocommerce",
54
+ });
55
+ }
56
+ }
57
+ const have = new Set();
58
+ if (!dryRun || existing.has(def.slug)) {
59
+ const current = await client.fields(def.slug).catch(() => []);
60
+ for (const f of current)
61
+ have.add(f.slug);
62
+ }
63
+ let i = 0;
64
+ for (const f of def.fields) {
65
+ i += 10;
66
+ if (have.has(f.slug))
67
+ continue;
68
+ log.info(` ${dryRun ? "would add" : "adding"} field ${def.slug}.${f.slug} (${f.type})`);
69
+ if (!dryRun)
70
+ await client.createField(def.slug, fieldBody(f, i));
71
+ created += 1;
72
+ }
73
+ return created;
74
+ }
75
+ async function applyTaxonomy(client, def, existing, dryRun) {
76
+ if (existing.has(def.name))
77
+ return 0;
78
+ log.info(`${dryRun ? "would create" : "creating"} taxonomy ${def.name} (${def.hierarchical ? "hierarchical" : "flat"})`);
79
+ if (!dryRun) {
80
+ await client.createTaxonomy({
81
+ name: def.name,
82
+ label: def.label,
83
+ labelSingular: def.labelSingular,
84
+ hierarchical: def.hierarchical,
85
+ collections: def.collections,
86
+ });
87
+ }
88
+ return 1;
89
+ }
90
+ export async function schemaApply(cli) {
91
+ const dryRun = cli.flags.dryRun;
92
+ const emdashCfg = cli.emdash();
93
+ const client = new EmDashClient(emdashCfg);
94
+ log.step(`${dryRun ? "Checking" : "Applying"} catalog schema on ${emdashCfg.url}`);
95
+ const existingCollections = new Set((await client.collections()).map((c) => c.slug));
96
+ const existingTaxonomies = new Set((await client.taxonomies()).map((t) => t.name));
97
+ let fields = 0;
98
+ for (const def of catalogCollections)
99
+ fields += await applyCollection(client, def, existingCollections, dryRun);
100
+ const taxonomies = [...catalogTaxonomies];
101
+ let wooConnected = false;
102
+ try {
103
+ const woo = new WooClient(cli.woo());
104
+ for await (const a of woo.all("products/attributes"))
105
+ taxonomies.push(attributeTaxonomy(a));
106
+ wooConnected = true;
107
+ }
108
+ catch (e) {
109
+ if (e instanceof Error && /connection missing/.test(e.message)) {
110
+ log.warn("No WooCommerce connection given, attribute taxonomies (pa_*) are skipped. Re-run with the store connected before importing.");
111
+ }
112
+ else {
113
+ throw e;
114
+ }
115
+ }
116
+ let created = 0;
117
+ for (const t of taxonomies)
118
+ created += await applyTaxonomy(client, t, existingTaxonomies, dryRun);
119
+ log.ok(`${dryRun ? "Would add" : "Added"} ${fields} fields and ${created} taxonomies${wooConnected ? "" : " (static set only)"}. Collections present: ${catalogCollections.map((c) => c.slug).join(", ")}.`);
120
+ }