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 +11 -0
- package/LICENSE +9 -0
- package/README.md +57 -0
- package/dist/cli.js +41 -0
- package/dist/commands/analyze.js +117 -0
- package/dist/commands/import.js +211 -0
- package/dist/commands/redirects.js +63 -0
- package/dist/commands/schema.js +120 -0
- package/dist/config.js +91 -0
- package/dist/emdash/client.js +174 -0
- package/dist/import/convert.js +177 -0
- package/dist/import/entries.js +133 -0
- package/dist/import/images.js +44 -0
- package/dist/import/media.js +62 -0
- package/dist/import/terms.js +191 -0
- package/dist/log.js +10 -0
- package/dist/schema/catalog.js +136 -0
- package/dist/woo/client.js +59 -0
- package/dist/woo/types.js +1 -0
- package/docs/target-schema.md +104 -0
- package/package.json +52 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { log } from "../log.js";
|
|
2
|
+
import { attributeTaxonomyName } from "../schema/catalog.js";
|
|
3
|
+
/**
|
|
4
|
+
* Creates EmDash terms on demand, only for terms that imported products
|
|
5
|
+
* reference (plus their ancestors), and remembers the mapping so entries can
|
|
6
|
+
* be assigned by slug.
|
|
7
|
+
*
|
|
8
|
+
* Why on demand: multilingual WooCommerce stores hold one copy of every term
|
|
9
|
+
* per language, all sharing a slug. Importing one language must not drag the
|
|
10
|
+
* other languages' terms along. A term is matched to an existing EmDash term
|
|
11
|
+
* by slug, so re-runs reuse what is there.
|
|
12
|
+
*/
|
|
13
|
+
export class TermSync {
|
|
14
|
+
woo;
|
|
15
|
+
emdash;
|
|
16
|
+
dryRun;
|
|
17
|
+
locale;
|
|
18
|
+
/** taxonomy -> all WooCommerce terms by id (read once per taxonomy) */
|
|
19
|
+
wooTerms = new Map();
|
|
20
|
+
/** taxonomy -> all WooCommerce terms by lowercased name (attributes are referenced by name) */
|
|
21
|
+
wooByName = new Map();
|
|
22
|
+
/** taxonomy -> EmDash terms by slug */
|
|
23
|
+
emdashTerms = new Map();
|
|
24
|
+
/** taxonomy -> woo term id -> EmDash term */
|
|
25
|
+
resolved = new Map();
|
|
26
|
+
/** taxonomy -> slug -> woo term id that claimed it in this run */
|
|
27
|
+
slugOwner = new Map();
|
|
28
|
+
/** taxonomy -> woo term id -> in-flight resolution, so concurrent callers share one create */
|
|
29
|
+
inflight = new Map();
|
|
30
|
+
/** woo attribute taxonomy slug (pa_color) -> EmDash taxonomy name (pa_color) */
|
|
31
|
+
attributeTaxonomies = new Map();
|
|
32
|
+
created = 0;
|
|
33
|
+
constructor(woo, emdash, dryRun, locale) {
|
|
34
|
+
this.woo = woo;
|
|
35
|
+
this.emdash = emdash;
|
|
36
|
+
this.dryRun = dryRun;
|
|
37
|
+
this.locale = locale;
|
|
38
|
+
}
|
|
39
|
+
/** Reads the store's term lists once. Nothing is written here. */
|
|
40
|
+
async load() {
|
|
41
|
+
await this.loadTaxonomy("product_category", "products/categories");
|
|
42
|
+
await this.loadTaxonomy("product_tag", "products/tags");
|
|
43
|
+
const brands = await this.woo.optional("products/brands", { per_page: 1 });
|
|
44
|
+
if (brands !== null)
|
|
45
|
+
await this.loadTaxonomy("product_brand", "products/brands");
|
|
46
|
+
else
|
|
47
|
+
log.warn("No products/brands endpoint on this store, product_brand stays empty.");
|
|
48
|
+
for await (const attr of this.woo.all("products/attributes")) {
|
|
49
|
+
const name = attributeTaxonomyName(attr.slug);
|
|
50
|
+
this.attributeTaxonomies.set(attr.slug, name);
|
|
51
|
+
await this.loadTaxonomy(name, `products/attributes/${attr.id}/terms`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
attributeTaxonomy = (wooSlug) => this.attributeTaxonomies.get(wooSlug) ?? null;
|
|
55
|
+
/** EmDash slug for a WooCommerce term id, creating the term (and its ancestors) when needed. */
|
|
56
|
+
async ensureById(taxonomy, wooId) {
|
|
57
|
+
const term = this.wooTerms.get(taxonomy)?.get(wooId);
|
|
58
|
+
if (!term)
|
|
59
|
+
return undefined;
|
|
60
|
+
return (await this.ensure(taxonomy, term))?.slug;
|
|
61
|
+
}
|
|
62
|
+
/** Same, for attribute options, which WooCommerce reports by name. */
|
|
63
|
+
async ensureByName(taxonomy, name) {
|
|
64
|
+
const term = this.wooByName.get(taxonomy)?.get(name.trim().toLowerCase());
|
|
65
|
+
if (!term)
|
|
66
|
+
return undefined;
|
|
67
|
+
return (await this.ensure(taxonomy, term))?.slug;
|
|
68
|
+
}
|
|
69
|
+
async loadTaxonomy(taxonomy, wooPath) {
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
const byName = new Map();
|
|
72
|
+
for await (const t of this.woo.all(wooPath)) {
|
|
73
|
+
byId.set(t.id, t);
|
|
74
|
+
if (!byName.has(t.name.trim().toLowerCase()))
|
|
75
|
+
byName.set(t.name.trim().toLowerCase(), t);
|
|
76
|
+
}
|
|
77
|
+
this.wooTerms.set(taxonomy, byId);
|
|
78
|
+
this.wooByName.set(taxonomy, byName);
|
|
79
|
+
const existing = new Map();
|
|
80
|
+
for (const t of await this.emdash.terms(taxonomy, this.locale))
|
|
81
|
+
existing.set(t.slug, { slug: t.slug, id: t.id });
|
|
82
|
+
this.emdashTerms.set(taxonomy, existing);
|
|
83
|
+
this.resolved.set(taxonomy, new Map());
|
|
84
|
+
this.slugOwner.set(taxonomy, new Map());
|
|
85
|
+
this.inflight.set(taxonomy, new Map());
|
|
86
|
+
log.info(`${taxonomy}: ${byId.size} terms in WooCommerce, ${existing.size} already in EmDash`);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Callers run concurrently (a product's categories are resolved with
|
|
90
|
+
* Promise.all), so one term must never be created twice: the first call
|
|
91
|
+
* for a term id does the work and later ones await the same promise.
|
|
92
|
+
*/
|
|
93
|
+
ensure(taxonomy, term) {
|
|
94
|
+
const done = this.resolved.get(taxonomy)?.get(term.id);
|
|
95
|
+
if (done)
|
|
96
|
+
return Promise.resolve(done);
|
|
97
|
+
const flights = this.inflight.get(taxonomy);
|
|
98
|
+
let p = flights.get(term.id);
|
|
99
|
+
if (!p) {
|
|
100
|
+
p = this.ensureNow(taxonomy, term).finally(() => flights.delete(term.id));
|
|
101
|
+
flights.set(term.id, p);
|
|
102
|
+
}
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
async ensureNow(taxonomy, term) {
|
|
106
|
+
let parent;
|
|
107
|
+
if (term.parent) {
|
|
108
|
+
const parentTerm = this.wooTerms.get(taxonomy)?.get(term.parent);
|
|
109
|
+
if (parentTerm)
|
|
110
|
+
parent = await this.ensure(taxonomy, parentTerm);
|
|
111
|
+
}
|
|
112
|
+
const existing = this.emdashTerms.get(taxonomy);
|
|
113
|
+
const owners = this.slugOwner.get(taxonomy);
|
|
114
|
+
// Two WooCommerce terms can share a slug (one per language). The first one
|
|
115
|
+
// in this run keeps the plain slug, later ones get their WooCommerce id appended.
|
|
116
|
+
let slug = termSlug(term);
|
|
117
|
+
const owner = owners.get(slug);
|
|
118
|
+
if (owner !== undefined && owner !== term.id)
|
|
119
|
+
slug = `${slug}-${term.id}`;
|
|
120
|
+
let ref = existing.get(slug);
|
|
121
|
+
if (!ref) {
|
|
122
|
+
ref = await this.create(taxonomy, slug, term, parent);
|
|
123
|
+
if (!ref) {
|
|
124
|
+
// Conflict: someone else holds that slug in EmDash. Re-read the list
|
|
125
|
+
// (another process may have created it) before falling back to a suffix.
|
|
126
|
+
await this.reload(taxonomy);
|
|
127
|
+
ref = existing.get(slug);
|
|
128
|
+
}
|
|
129
|
+
if (!ref) {
|
|
130
|
+
slug = `${termSlug(term)}-${term.id}`;
|
|
131
|
+
ref = existing.get(slug) ?? (await this.create(taxonomy, slug, term, parent));
|
|
132
|
+
if (!ref) {
|
|
133
|
+
await this.reload(taxonomy);
|
|
134
|
+
ref = existing.get(slug);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (!ref)
|
|
138
|
+
throw new Error(`${taxonomy}: could not create term ${term.slug} (${term.id})`);
|
|
139
|
+
existing.set(slug, ref);
|
|
140
|
+
this.created += 1;
|
|
141
|
+
}
|
|
142
|
+
owners.set(slug, term.id);
|
|
143
|
+
this.resolved.get(taxonomy).set(term.id, ref);
|
|
144
|
+
return ref;
|
|
145
|
+
}
|
|
146
|
+
async reload(taxonomy) {
|
|
147
|
+
const existing = this.emdashTerms.get(taxonomy);
|
|
148
|
+
for (const t of await this.emdash.terms(taxonomy, this.locale)) {
|
|
149
|
+
if (!existing.has(t.slug))
|
|
150
|
+
existing.set(t.slug, { slug: t.slug, id: t.id });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Returns undefined when EmDash reports the slug as already taken (409). */
|
|
154
|
+
async create(taxonomy, slug, term, parent) {
|
|
155
|
+
if (this.dryRun)
|
|
156
|
+
return { slug, id: `dry-run:${slug}` };
|
|
157
|
+
const body = { slug, label: term.name };
|
|
158
|
+
if (this.locale)
|
|
159
|
+
body.locale = this.locale;
|
|
160
|
+
if (parent)
|
|
161
|
+
body.parentId = parent.id;
|
|
162
|
+
if (term.description)
|
|
163
|
+
body.description = term.description;
|
|
164
|
+
try {
|
|
165
|
+
const made = await this.emdash.createTerm(taxonomy, body);
|
|
166
|
+
return { slug: made.slug, id: made.id };
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
if (e instanceof Error && /\b409\b|CONFLICT/.test(e.message))
|
|
170
|
+
return undefined;
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** WooCommerce percent-encodes non-Latin slugs; EmDash wants a plain slug. */
|
|
176
|
+
function termSlug(term) {
|
|
177
|
+
let s = term.slug;
|
|
178
|
+
try {
|
|
179
|
+
s = decodeURIComponent(s);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
/* keep as is */
|
|
183
|
+
}
|
|
184
|
+
s = s
|
|
185
|
+
.normalize("NFKD")
|
|
186
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
187
|
+
.toLowerCase()
|
|
188
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
189
|
+
.replace(/^-+|-+$/g, "");
|
|
190
|
+
return s || `term-${term.id}`;
|
|
191
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const useColor = process.stderr.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
const ESC = String.fromCharCode(27);
|
|
3
|
+
const paint = (code, s) => (useColor ? `${ESC}[${code}m${s}${ESC}[0m` : s);
|
|
4
|
+
export const log = {
|
|
5
|
+
info: (msg) => console.error(paint("36", "i ") + msg),
|
|
6
|
+
ok: (msg) => console.error(paint("32", "+ ") + msg),
|
|
7
|
+
warn: (msg) => console.error(paint("33", "! ") + msg),
|
|
8
|
+
error: (msg) => console.error(paint("31", "x ") + msg),
|
|
9
|
+
step: (msg) => console.error(paint("1", msg)),
|
|
10
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target EmDash schema for a WooCommerce catalog. This is the contract other
|
|
3
|
+
* tools (storefront themes, commerce plugins, exporters) can rely on; the
|
|
4
|
+
* human-readable version lives in docs/target-schema.md and must stay in sync.
|
|
5
|
+
*
|
|
6
|
+
* Slug rules come from EmDash: /^[a-z][a-z0-9_]*$/, max 63 chars, and a reserved
|
|
7
|
+
* list (id, slug, status, created_at, terms, bylines, ...). Nothing here uses them.
|
|
8
|
+
*/
|
|
9
|
+
export const PRODUCTS = "products";
|
|
10
|
+
export const VARIATIONS = "product_variations";
|
|
11
|
+
const STOCK_STATUS = ["instock", "outofstock", "onbackorder"];
|
|
12
|
+
const BACKORDERS = ["no", "notify", "yes"];
|
|
13
|
+
const TAX_STATUS = ["taxable", "shipping", "none"];
|
|
14
|
+
/** Fields shared by products and variations, in this order. */
|
|
15
|
+
function commerceFields() {
|
|
16
|
+
return [
|
|
17
|
+
{ slug: "sku", label: "SKU", type: "string", indexed: true, source: "sku" },
|
|
18
|
+
{ slug: "gtin", label: "GTIN / UPC / EAN / ISBN", type: "string", source: "global_unique_id" },
|
|
19
|
+
{ slug: "featured_image", label: "Featured image", type: "image", source: "images[0] (product) or image (variation), uploaded to EmDash media" },
|
|
20
|
+
{ slug: "regular_price", label: "Regular price", type: "number", source: "regular_price, decimal in the store currency" },
|
|
21
|
+
{ slug: "sale_price", label: "Sale price", type: "number", source: "sale_price, empty when not on sale" },
|
|
22
|
+
{ slug: "price", label: "Current price", type: "number", indexed: true, source: "price, the effective price WooCommerce computed" },
|
|
23
|
+
{ slug: "currency", label: "Currency", type: "string", source: "store setting woocommerce_currency (settings/general), ISO 4217" },
|
|
24
|
+
{ slug: "sale_from", label: "Sale starts", type: "datetime", source: "date_on_sale_from_gmt" },
|
|
25
|
+
{ slug: "sale_to", label: "Sale ends", type: "datetime", source: "date_on_sale_to_gmt" },
|
|
26
|
+
{ slug: "stock_status", label: "Stock status", type: "select", validation: { options: STOCK_STATUS }, defaultValue: "instock", source: "stock_status" },
|
|
27
|
+
{ slug: "manage_stock", label: "Manage stock", type: "boolean", defaultValue: false, source: "manage_stock" },
|
|
28
|
+
{ slug: "stock_quantity", label: "Stock quantity", type: "integer", source: "stock_quantity" },
|
|
29
|
+
{ slug: "backorders", label: "Backorders", type: "select", validation: { options: BACKORDERS }, defaultValue: "no", source: "backorders" },
|
|
30
|
+
{ slug: "low_stock_amount", label: "Low stock threshold", type: "integer", source: "low_stock_amount" },
|
|
31
|
+
{ slug: "weight", label: "Weight", type: "number", source: "weight" },
|
|
32
|
+
{ slug: "length", label: "Length", type: "number", source: "dimensions.length" },
|
|
33
|
+
{ slug: "width", label: "Width", type: "number", source: "dimensions.width" },
|
|
34
|
+
{ slug: "height", label: "Height", type: "number", source: "dimensions.height" },
|
|
35
|
+
{ slug: "weight_unit", label: "Weight unit", type: "string", source: "store setting woocommerce_weight_unit (settings/products)" },
|
|
36
|
+
{ slug: "dimension_unit", label: "Dimension unit", type: "string", source: "store setting woocommerce_dimension_unit (settings/products)" },
|
|
37
|
+
{ slug: "shipping_class", label: "Shipping class", type: "string", source: "shipping_class (slug)" },
|
|
38
|
+
{ slug: "tax_status", label: "Tax status", type: "select", validation: { options: TAX_STATUS }, defaultValue: "taxable", source: "tax_status" },
|
|
39
|
+
{ slug: "tax_class", label: "Tax class", type: "string", source: "tax_class" },
|
|
40
|
+
{ slug: "virtual", label: "Virtual", type: "boolean", defaultValue: false, source: "virtual" },
|
|
41
|
+
{ slug: "downloadable", label: "Downloadable", type: "boolean", defaultValue: false, source: "downloadable" },
|
|
42
|
+
{ slug: "downloads", label: "Downloadable files", type: "json", source: "downloads, array of { id, name, file }" },
|
|
43
|
+
{ slug: "download_limit", label: "Download limit", type: "integer", source: "download_limit, -1 means unlimited" },
|
|
44
|
+
{ slug: "download_expiry", label: "Download expiry (days)", type: "integer", source: "download_expiry, -1 means never" },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
function bookkeepingFields() {
|
|
48
|
+
return [
|
|
49
|
+
{ slug: "woo_id", label: "WooCommerce ID", type: "integer", required: true, unique: true, indexed: true, source: "id, the key for idempotent re-runs" },
|
|
50
|
+
{ slug: "woo_permalink", label: "WooCommerce URL", type: "url", source: "permalink, kept for redirects" },
|
|
51
|
+
{ slug: "woo_modified", label: "Last modified in WooCommerce", type: "datetime", indexed: true, source: "date_modified_gmt, for incremental syncs" },
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
export const productsCollection = {
|
|
55
|
+
slug: PRODUCTS,
|
|
56
|
+
label: "Products",
|
|
57
|
+
labelSingular: "Product",
|
|
58
|
+
description: "Catalog imported from WooCommerce. One entry per product; variations live in Product variations.",
|
|
59
|
+
icon: "package",
|
|
60
|
+
supports: ["drafts", "revisions", "search", "seo"],
|
|
61
|
+
routable: true,
|
|
62
|
+
hidden: false,
|
|
63
|
+
hasSeo: true,
|
|
64
|
+
fields: [
|
|
65
|
+
{ slug: "title", label: "Name", type: "string", required: true, source: "name" },
|
|
66
|
+
{ slug: "content", label: "Description", type: "portableText", source: "description, HTML converted to Portable Text" },
|
|
67
|
+
{ slug: "excerpt", label: "Short description", type: "text", source: "short_description, HTML converted to plain text" },
|
|
68
|
+
{ slug: "product_type", label: "Product type", type: "select", required: true, validation: { options: ["simple", "variable", "grouped", "external"] }, defaultValue: "simple", source: "type" },
|
|
69
|
+
...commerceFields(),
|
|
70
|
+
{ slug: "gallery", label: "Gallery", type: "repeater", validation: { subFields: [{ slug: "image", type: "image", label: "Image" }] }, source: "images[1..], each uploaded to EmDash media" },
|
|
71
|
+
{ slug: "external_url", label: "External URL", type: "url", source: "external_url (external products)" },
|
|
72
|
+
{ slug: "button_text", label: "Button text", type: "string", source: "button_text (external products)" },
|
|
73
|
+
{ slug: "featured", label: "Featured", type: "boolean", defaultValue: false, indexed: true, source: "featured" },
|
|
74
|
+
{ slug: "catalog_visibility", label: "Catalog visibility", type: "select", validation: { options: ["visible", "catalog", "search", "hidden"] }, defaultValue: "visible", source: "catalog_visibility" },
|
|
75
|
+
{ slug: "menu_order", label: "Sort order", type: "integer", defaultValue: 0, indexed: true, source: "menu_order" },
|
|
76
|
+
{ slug: "sold_individually", label: "Sold individually", type: "boolean", defaultValue: false, source: "sold_individually" },
|
|
77
|
+
{ slug: "purchase_note", label: "Purchase note", type: "text", source: "purchase_note" },
|
|
78
|
+
{ slug: "reviews_allowed", label: "Reviews allowed", type: "boolean", defaultValue: true, source: "reviews_allowed" },
|
|
79
|
+
{ slug: "average_rating", label: "Average rating", type: "number", source: "average_rating" },
|
|
80
|
+
{ slug: "rating_count", label: "Rating count", type: "integer", source: "rating_count" },
|
|
81
|
+
{ slug: "attributes", label: "Attributes", type: "json", source: "attributes, array of { name, taxonomy, visible, variation, options[] }; taxonomy is the EmDash taxonomy name for global attributes, null for custom ones" },
|
|
82
|
+
{ slug: "default_attributes", label: "Default variation attributes", type: "json", source: "default_attributes" },
|
|
83
|
+
{ slug: "upsells", label: "Upsells", type: "reference", options: { collection: PRODUCTS, allowMultiple: true }, source: "upsell_ids, resolved to EmDash entry ids" },
|
|
84
|
+
{ slug: "cross_sells", label: "Cross-sells", type: "reference", options: { collection: PRODUCTS, allowMultiple: true }, source: "cross_sell_ids" },
|
|
85
|
+
{ slug: "grouped_products", label: "Grouped products", type: "reference", options: { collection: PRODUCTS, allowMultiple: true }, source: "grouped_products (grouped products)" },
|
|
86
|
+
...bookkeepingFields(),
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
export const variationsCollection = {
|
|
90
|
+
slug: VARIATIONS,
|
|
91
|
+
label: "Product variations",
|
|
92
|
+
labelSingular: "Product variation",
|
|
93
|
+
description: "One entry per WooCommerce variation, linked to its parent product.",
|
|
94
|
+
icon: "layers",
|
|
95
|
+
supports: ["drafts", "revisions"],
|
|
96
|
+
routable: false,
|
|
97
|
+
hidden: false,
|
|
98
|
+
hasSeo: false,
|
|
99
|
+
fields: [
|
|
100
|
+
{ slug: "title", label: "Name", type: "string", required: true, source: "name, WooCommerce builds it from the parent name and the attribute values" },
|
|
101
|
+
{ slug: "product", label: "Parent product", type: "reference", required: true, indexed: true, options: { collection: PRODUCTS }, source: "parent_id, resolved to the EmDash product entry" },
|
|
102
|
+
{ slug: "content", label: "Description", type: "portableText", source: "description" },
|
|
103
|
+
...commerceFields(),
|
|
104
|
+
{ slug: "attributes", label: "Attribute values", type: "json", source: "attributes, array of { name, taxonomy, option }" },
|
|
105
|
+
{ slug: "menu_order", label: "Sort order", type: "integer", defaultValue: 0, indexed: true, source: "menu_order" },
|
|
106
|
+
...bookkeepingFields(),
|
|
107
|
+
],
|
|
108
|
+
};
|
|
109
|
+
export const catalogCollections = [productsCollection, variationsCollection];
|
|
110
|
+
export const catalogTaxonomies = [
|
|
111
|
+
{ name: "product_category", label: "Product categories", labelSingular: "Product category", hierarchical: true, collections: [PRODUCTS], source: "product_cat" },
|
|
112
|
+
{ name: "product_tag", label: "Product tags", labelSingular: "Product tag", hierarchical: false, collections: [PRODUCTS], source: "product_tag" },
|
|
113
|
+
{ name: "product_brand", label: "Brands", labelSingular: "Brand", hierarchical: true, collections: [PRODUCTS], source: "product_brand (WooCommerce 9.6 and later)" },
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* EmDash taxonomy name for a WooCommerce global attribute taxonomy.
|
|
117
|
+
* WooCommerce allows hyphens (pa_plug-type); EmDash slugs do not.
|
|
118
|
+
*/
|
|
119
|
+
export function attributeTaxonomyName(wooTaxonomy) {
|
|
120
|
+
const name = wooTaxonomy
|
|
121
|
+
.toLowerCase()
|
|
122
|
+
.replace(/[^a-z0-9_]+/g, "_")
|
|
123
|
+
.replace(/^_+/, "")
|
|
124
|
+
.replace(/_+$/, "");
|
|
125
|
+
return name.startsWith("pa_") ? name : `pa_${name}`;
|
|
126
|
+
}
|
|
127
|
+
export function attributeTaxonomy(attr) {
|
|
128
|
+
return {
|
|
129
|
+
name: attributeTaxonomyName(attr.slug),
|
|
130
|
+
label: attr.name,
|
|
131
|
+
labelSingular: attr.name,
|
|
132
|
+
hierarchical: false,
|
|
133
|
+
collections: [PRODUCTS, VARIATIONS],
|
|
134
|
+
source: `global attribute ${attr.slug}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Minimal WooCommerce REST API (wc/v3) reader. Read permission is all it needs. */
|
|
2
|
+
export class WooClient {
|
|
3
|
+
base;
|
|
4
|
+
auth;
|
|
5
|
+
constructor(cfg) {
|
|
6
|
+
this.base = `${cfg.url}/wp-json/wc/v3`;
|
|
7
|
+
this.auth = "Basic " + Buffer.from(`${cfg.consumerKey}:${cfg.consumerSecret}`).toString("base64");
|
|
8
|
+
}
|
|
9
|
+
async get(path, params = {}) {
|
|
10
|
+
const url = new URL(`${this.base}/${path.replace(/^\//, "")}`);
|
|
11
|
+
for (const [k, v] of Object.entries(params))
|
|
12
|
+
url.searchParams.set(k, String(v));
|
|
13
|
+
const res = await fetch(url, { headers: { Authorization: this.auth, Accept: "application/json" } });
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
let detail = "";
|
|
16
|
+
try {
|
|
17
|
+
const body = (await res.json());
|
|
18
|
+
detail = body.message ? ` (${body.code ?? "error"}: ${body.message})` : "";
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* not JSON */
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`WooCommerce ${res.status} on ${url.pathname}${detail}`);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
data: (await res.json()),
|
|
27
|
+
totalPages: Number(res.headers.get("x-wp-totalpages") ?? "1"),
|
|
28
|
+
total: Number(res.headers.get("x-wp-total") ?? "0"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Iterate every item of a paginated collection endpoint, 100 per request. */
|
|
32
|
+
async *all(path, params = {}, limit) {
|
|
33
|
+
let page = 1;
|
|
34
|
+
let yielded = 0;
|
|
35
|
+
for (;;) {
|
|
36
|
+
const { data, totalPages } = await this.get(path, { ...params, per_page: 100, page });
|
|
37
|
+
for (const item of data) {
|
|
38
|
+
yield item;
|
|
39
|
+
yielded += 1;
|
|
40
|
+
if (limit !== undefined && yielded >= limit)
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (page >= totalPages || data.length === 0)
|
|
44
|
+
return;
|
|
45
|
+
page += 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Returns null when the endpoint does not exist on this store (older WooCommerce). */
|
|
49
|
+
async optional(path, params = {}) {
|
|
50
|
+
try {
|
|
51
|
+
return (await this.get(path, params)).data;
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (e instanceof Error && /WooCommerce 404/.test(e.message))
|
|
55
|
+
return null;
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Target EmDash schema for a WooCommerce catalog
|
|
2
|
+
|
|
3
|
+
This is the shape woo2emdash creates in an EmDash site and fills from a WooCommerce store. It is meant as a stable contract that storefront themes, commerce plugins and other tools can build on, so changes here are versioned. The code that creates it is `src/schema/catalog.ts`; `woo2emdash schema show` prints the same list from the code, which wins if the two ever disagree.
|
|
4
|
+
|
|
5
|
+
## Conventions
|
|
6
|
+
|
|
7
|
+
Money is stored as a decimal number in the store currency, exactly as WooCommerce reports it, with the currency code kept on every entry. Nothing is converted to minor units, that is a consumer's choice.
|
|
8
|
+
|
|
9
|
+
Every entry carries `woo_id`, the WooCommerce numeric ID, unique and indexed. Re-running the importer looks entries up by `woo_id` and updates them instead of creating duplicates. `woo_modified` allows incremental syncs and `woo_permalink` allows building redirects from the old shop URLs.
|
|
10
|
+
|
|
11
|
+
Field slugs follow EmDash rules, lowercase letters, digits and underscores, so WooCommerce's hyphenated attribute taxonomies are renamed (pa_plug-type becomes pa_plug_type). The original WooCommerce taxonomy slug is kept inside the `attributes` JSON.
|
|
12
|
+
|
|
13
|
+
Images referenced inside descriptions are uploaded to EmDash media too, and the Portable Text image blocks point at the EmDash media item, so nothing in an imported description depends on the old site staying up.
|
|
14
|
+
|
|
15
|
+
Entry status maps WooCommerce publish to published and everything else (draft, pending, private) to draft. Creation and publication dates are preserved from WooCommerce.
|
|
16
|
+
|
|
17
|
+
## Collection `products`
|
|
18
|
+
|
|
19
|
+
Routable, with drafts, revisions, search and SEO. One entry per WooCommerce product of any type. Variations are separate entries in `product_variations`.
|
|
20
|
+
|
|
21
|
+
| Field | Type | Source in wc/v3 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| title | string, required | name |
|
|
24
|
+
| content | portableText | description, HTML converted to Portable Text |
|
|
25
|
+
| excerpt | text | short_description, HTML stripped to text |
|
|
26
|
+
| product_type | select simple, variable, grouped, external | type |
|
|
27
|
+
| sku | string, indexed | sku |
|
|
28
|
+
| gtin | string | global_unique_id |
|
|
29
|
+
| featured_image | image | images[0], uploaded to EmDash media |
|
|
30
|
+
| regular_price | number | regular_price |
|
|
31
|
+
| sale_price | number | sale_price |
|
|
32
|
+
| price | number, indexed | price |
|
|
33
|
+
| currency | string | woocommerce_currency (settings/general) |
|
|
34
|
+
| sale_from | datetime | date_on_sale_from_gmt |
|
|
35
|
+
| sale_to | datetime | date_on_sale_to_gmt |
|
|
36
|
+
| stock_status | select instock, outofstock, onbackorder | stock_status |
|
|
37
|
+
| manage_stock | boolean | manage_stock |
|
|
38
|
+
| stock_quantity | integer | stock_quantity |
|
|
39
|
+
| backorders | select no, notify, yes | backorders |
|
|
40
|
+
| low_stock_amount | integer | low_stock_amount |
|
|
41
|
+
| weight | number | weight |
|
|
42
|
+
| length, width, height | number | dimensions |
|
|
43
|
+
| weight_unit, dimension_unit | string | woocommerce_weight_unit, woocommerce_dimension_unit (settings/products) |
|
|
44
|
+
| shipping_class | string | shipping_class slug |
|
|
45
|
+
| tax_status | select taxable, shipping, none | tax_status |
|
|
46
|
+
| tax_class | string | tax_class |
|
|
47
|
+
| virtual | boolean | virtual |
|
|
48
|
+
| downloadable | boolean | downloadable |
|
|
49
|
+
| downloads | json | downloads, array of { id, name, file } |
|
|
50
|
+
| download_limit | integer | download_limit, -1 is unlimited |
|
|
51
|
+
| download_expiry | integer | download_expiry in days, -1 is never |
|
|
52
|
+
| gallery | repeater of image | images[1..], each uploaded to EmDash media |
|
|
53
|
+
| external_url | url | external_url |
|
|
54
|
+
| button_text | string | button_text |
|
|
55
|
+
| featured | boolean, indexed | featured |
|
|
56
|
+
| catalog_visibility | select visible, catalog, search, hidden | catalog_visibility |
|
|
57
|
+
| menu_order | integer, indexed | menu_order |
|
|
58
|
+
| sold_individually | boolean | sold_individually |
|
|
59
|
+
| purchase_note | text | purchase_note |
|
|
60
|
+
| reviews_allowed | boolean | reviews_allowed |
|
|
61
|
+
| average_rating | number | average_rating |
|
|
62
|
+
| rating_count | integer | rating_count |
|
|
63
|
+
| attributes | json | attributes, array of { name, taxonomy, visible, variation, options[] }, taxonomy is the EmDash taxonomy name for global attributes and null for custom ones |
|
|
64
|
+
| default_attributes | json | default_attributes |
|
|
65
|
+
| upsells | reference to products, multiple | upsell_ids |
|
|
66
|
+
| cross_sells | reference to products, multiple | cross_sell_ids |
|
|
67
|
+
| grouped_products | reference to products, multiple | grouped_products |
|
|
68
|
+
| woo_id | integer, required, unique, indexed | id |
|
|
69
|
+
| woo_permalink | url | permalink |
|
|
70
|
+
| woo_modified | datetime, indexed | date_modified_gmt |
|
|
71
|
+
|
|
72
|
+
## Collection `product_variations`
|
|
73
|
+
|
|
74
|
+
Not routable, drafts and revisions only. One entry per WooCommerce variation.
|
|
75
|
+
|
|
76
|
+
| Field | Type | Source in wc/v3 |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| title | string, required | name |
|
|
79
|
+
| product | reference to products, required, indexed | parent_id |
|
|
80
|
+
| content | portableText | description |
|
|
81
|
+
| sku, gtin, featured_image, regular_price, sale_price, price, currency, sale_from, sale_to | as in products | same fields, featured_image from image |
|
|
82
|
+
| stock_status, manage_stock, stock_quantity, backorders, low_stock_amount | as in products | same fields |
|
|
83
|
+
| weight, length, width, height, weight_unit, dimension_unit, shipping_class | as in products | same fields |
|
|
84
|
+
| tax_status, tax_class, virtual, downloadable, downloads, download_limit, download_expiry | as in products | same fields |
|
|
85
|
+
| attributes | json | attributes, array of { name, taxonomy, option } |
|
|
86
|
+
| menu_order | integer, indexed | menu_order |
|
|
87
|
+
| woo_id, woo_permalink, woo_modified | as in products | id, permalink, date_modified_gmt |
|
|
88
|
+
|
|
89
|
+
## Taxonomies
|
|
90
|
+
|
|
91
|
+
| EmDash taxonomy | Shape | Applies to | Source |
|
|
92
|
+
|---|---|---|---|
|
|
93
|
+
| product_category | hierarchical | products | product_cat |
|
|
94
|
+
| product_tag | flat | products | product_tag |
|
|
95
|
+
| product_brand | hierarchical | products | product_brand (WooCommerce 9.6 and later, skipped when the endpoint is missing) |
|
|
96
|
+
| pa_<attribute> | flat, one per global attribute | products, product_variations | the attribute taxonomy, hyphens replaced by underscores |
|
|
97
|
+
|
|
98
|
+
Custom (per-product) attributes have no taxonomy. They live only in the `attributes` JSON.
|
|
99
|
+
|
|
100
|
+
Term slugs and labels are copied from WooCommerce, category parents are preserved. Term assignments are made on import; the variation entries are assigned the attribute terms they carry.
|
|
101
|
+
|
|
102
|
+
## Out of scope for v1
|
|
103
|
+
|
|
104
|
+
Customers, orders, reviews, coupons, shipping zones and tax rates. Reviews are WooCommerce comments and could map to EmDash comments later. Orders need a schema no commerce plugin has agreed on yet.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "woo2emdash",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Migrate a WooCommerce catalog into EmDash CMS collections",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"woo2emdash": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"docs",
|
|
13
|
+
"README.md",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.16"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"dev": "node --experimental-strip-types src/cli.ts",
|
|
23
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
24
|
+
"test": "node --experimental-strip-types --test \"test/**/*.test.ts\"",
|
|
25
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0",
|
|
29
|
+
"typescript": "^5.9.0"
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@emdash-cms/gutenberg-to-portable-text": "^0.37.0"
|
|
33
|
+
},
|
|
34
|
+
"author": "Rafael Minuesa",
|
|
35
|
+
"keywords": [
|
|
36
|
+
"woocommerce",
|
|
37
|
+
"emdash",
|
|
38
|
+
"emdash-cms",
|
|
39
|
+
"migration",
|
|
40
|
+
"import",
|
|
41
|
+
"wordpress",
|
|
42
|
+
"astro"
|
|
43
|
+
],
|
|
44
|
+
"homepage": "https://prowoos.com",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/ProWoos-Devs/woo2emdash.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/ProWoos-Devs/woo2emdash/issues"
|
|
51
|
+
}
|
|
52
|
+
}
|