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
package/dist/config.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
const OPTIONS = {
|
|
3
|
+
"woo-url": { type: "string" },
|
|
4
|
+
"woo-key": { type: "string" },
|
|
5
|
+
"woo-secret": { type: "string" },
|
|
6
|
+
"emdash-url": { type: "string" },
|
|
7
|
+
"emdash-token": { type: "string" },
|
|
8
|
+
lang: { type: "string" },
|
|
9
|
+
locale: { type: "string" },
|
|
10
|
+
out: { type: "string" },
|
|
11
|
+
pattern: { type: "string" },
|
|
12
|
+
since: { type: "string" },
|
|
13
|
+
limit: { type: "string" },
|
|
14
|
+
json: { type: "boolean", default: false },
|
|
15
|
+
"dry-run": { type: "boolean", default: false },
|
|
16
|
+
"skip-media": { type: "boolean", default: false },
|
|
17
|
+
"skip-variations": { type: "boolean", default: false },
|
|
18
|
+
help: { type: "boolean", short: "h", default: false },
|
|
19
|
+
};
|
|
20
|
+
export function parseCli(argv) {
|
|
21
|
+
const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true });
|
|
22
|
+
const env = process.env;
|
|
23
|
+
const woo = () => {
|
|
24
|
+
const url = values["woo-url"] ?? env.WOO_URL;
|
|
25
|
+
const consumerKey = values["woo-key"] ?? env.WOO_CONSUMER_KEY;
|
|
26
|
+
const consumerSecret = values["woo-secret"] ?? env.WOO_CONSUMER_SECRET;
|
|
27
|
+
if (!url || !consumerKey || !consumerSecret) {
|
|
28
|
+
throw new Error("WooCommerce connection missing. Pass --woo-url, --woo-key and --woo-secret, or set WOO_URL, WOO_CONSUMER_KEY and WOO_CONSUMER_SECRET.");
|
|
29
|
+
}
|
|
30
|
+
return { url: url.replace(/\/+$/, ""), consumerKey, consumerSecret };
|
|
31
|
+
};
|
|
32
|
+
const emdash = () => ({
|
|
33
|
+
url: (values["emdash-url"] ?? env.EMDASH_URL ?? "http://127.0.0.1:4321").replace(/\/+$/, ""),
|
|
34
|
+
token: values["emdash-token"] ?? env.EMDASH_TOKEN,
|
|
35
|
+
});
|
|
36
|
+
if (values.since !== undefined && Number.isNaN(new Date(values.since).getTime())) {
|
|
37
|
+
throw new Error("--since must be an ISO 8601 date, e.g. 2026-09-01 or 2026-09-01T00:00:00Z.");
|
|
38
|
+
}
|
|
39
|
+
const limitRaw = values.limit;
|
|
40
|
+
const limit = limitRaw === undefined ? undefined : Number.parseInt(limitRaw, 10);
|
|
41
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
42
|
+
throw new Error("--limit must be a positive integer.");
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
command: positionals,
|
|
46
|
+
flags: {
|
|
47
|
+
json: values.json,
|
|
48
|
+
dryRun: values["dry-run"],
|
|
49
|
+
help: values.help,
|
|
50
|
+
lang: values.lang,
|
|
51
|
+
locale: values.locale,
|
|
52
|
+
out: values.out,
|
|
53
|
+
pattern: values.pattern,
|
|
54
|
+
since: values.since,
|
|
55
|
+
limit,
|
|
56
|
+
skipMedia: values["skip-media"],
|
|
57
|
+
skipVariations: values["skip-variations"],
|
|
58
|
+
},
|
|
59
|
+
woo,
|
|
60
|
+
emdash,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export const HELP = `woo2emdash <command> [options]
|
|
64
|
+
|
|
65
|
+
Commands
|
|
66
|
+
analyze Read the WooCommerce catalog and report what would be imported
|
|
67
|
+
schema show Print the target EmDash schema (collections, fields, taxonomies)
|
|
68
|
+
schema apply Create the target schema in EmDash, skipping anything that already exists
|
|
69
|
+
import Copy terms, products, variations and images into EmDash (safe to re-run)
|
|
70
|
+
redirects Write a redirect map from the old WooCommerce product URLs to the EmDash ones
|
|
71
|
+
|
|
72
|
+
Connection (flags override environment variables)
|
|
73
|
+
--woo-url <url> WOO_URL Store URL, e.g. https://shop.example.com
|
|
74
|
+
--woo-key <ck_...> WOO_CONSUMER_KEY WooCommerce REST API consumer key (Read permission is enough)
|
|
75
|
+
--woo-secret <cs_...> WOO_CONSUMER_SECRET WooCommerce REST API consumer secret
|
|
76
|
+
--emdash-url <url> EMDASH_URL EmDash site URL (default http://127.0.0.1:4321)
|
|
77
|
+
--emdash-token <token> EMDASH_TOKEN EmDash API token. Omitted on localhost, the dev bypass is used.
|
|
78
|
+
|
|
79
|
+
Options
|
|
80
|
+
--lang <code> Only products whose permalink starts with /<code>/ (multilingual stores)
|
|
81
|
+
--locale <code> Store entries and terms under this EmDash locale (must be configured on the site)
|
|
82
|
+
--out <file> redirects: write the map to this file instead of stdout
|
|
83
|
+
--pattern <pattern> redirects: destination pattern, e.g. /products/{slug} (default: the collection's URL pattern)
|
|
84
|
+
--since <date> import: only products modified in WooCommerce after this date (incremental runs)
|
|
85
|
+
--limit <n> Stop after n products (for quick checks)
|
|
86
|
+
--dry-run Report what would happen without writing to EmDash
|
|
87
|
+
--skip-media Do not download or upload images
|
|
88
|
+
--skip-variations Import products only
|
|
89
|
+
--json Machine-readable output
|
|
90
|
+
-h, --help This text
|
|
91
|
+
`;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client for the documented EmDash REST API (/_emdash/api). Kept independent
|
|
3
|
+
* of the `emdash` npm package on purpose, that package drags the whole CMS and its
|
|
4
|
+
* Astro peer dependencies into any tool that imports it.
|
|
5
|
+
*
|
|
6
|
+
* Auth: Bearer API token when given. On localhost with no token, the dev bypass
|
|
7
|
+
* endpoint issues a session cookie (the same trick the official CLI uses). It
|
|
8
|
+
* returns 403 on production sites, so it cannot be abused there.
|
|
9
|
+
*/
|
|
10
|
+
export class EmDashClient {
|
|
11
|
+
base;
|
|
12
|
+
origin;
|
|
13
|
+
token;
|
|
14
|
+
cookie = null;
|
|
15
|
+
bypassPromise = null;
|
|
16
|
+
constructor(cfg) {
|
|
17
|
+
this.base = `${cfg.url}/_emdash/api`;
|
|
18
|
+
this.origin = new URL(cfg.url).origin;
|
|
19
|
+
this.token = cfg.token;
|
|
20
|
+
}
|
|
21
|
+
async devBypass() {
|
|
22
|
+
const res = await fetch(`${this.base}/auth/dev-bypass`, { redirect: "manual" });
|
|
23
|
+
const setCookie = res.headers.get("set-cookie");
|
|
24
|
+
const match = setCookie?.match(/^([^=]+=[^;]+)/);
|
|
25
|
+
if (match)
|
|
26
|
+
this.cookie = match[1] ?? null;
|
|
27
|
+
if (res.body)
|
|
28
|
+
await res.text().catch(() => undefined);
|
|
29
|
+
if (!this.cookie) {
|
|
30
|
+
throw new Error(`EmDash dev bypass gave no session (HTTP ${res.status}). Pass --emdash-token for a non-local site.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async headers(method) {
|
|
34
|
+
const h = new Headers({ Accept: "application/json" });
|
|
35
|
+
if (this.token) {
|
|
36
|
+
h.set("Authorization", `Bearer ${this.token}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
if (!this.cookie) {
|
|
40
|
+
this.bypassPromise ??= this.devBypass();
|
|
41
|
+
await this.bypassPromise;
|
|
42
|
+
}
|
|
43
|
+
if (this.cookie)
|
|
44
|
+
h.set("Cookie", this.cookie);
|
|
45
|
+
}
|
|
46
|
+
if (method !== "GET") {
|
|
47
|
+
// CSRF header EmDash expects on mutations, plus Origin for Astro's own check.
|
|
48
|
+
h.set("X-EmDash-Request", "1");
|
|
49
|
+
h.set("Origin", this.origin);
|
|
50
|
+
}
|
|
51
|
+
return h;
|
|
52
|
+
}
|
|
53
|
+
async request(method, path, body) {
|
|
54
|
+
const h = await this.headers(method);
|
|
55
|
+
let payload = null;
|
|
56
|
+
if (body instanceof FormData) {
|
|
57
|
+
payload = body;
|
|
58
|
+
}
|
|
59
|
+
else if (body !== undefined) {
|
|
60
|
+
h.set("Content-Type", "application/json");
|
|
61
|
+
payload = JSON.stringify(body);
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch(`${this.base}${path}`, { method, headers: h, body: payload });
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let json = {};
|
|
66
|
+
try {
|
|
67
|
+
json = text ? JSON.parse(text) : {};
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error(`EmDash ${res.status} on ${method} ${path}: non-JSON response`);
|
|
71
|
+
}
|
|
72
|
+
if (!res.ok || json.success === false) {
|
|
73
|
+
const err = json.error ?? {};
|
|
74
|
+
throw new Error(`EmDash ${res.status} on ${method} ${path}: ${err.code ?? "error"} ${err.message ?? text.slice(0, 200)}`);
|
|
75
|
+
}
|
|
76
|
+
return json.data;
|
|
77
|
+
}
|
|
78
|
+
// Schema
|
|
79
|
+
async collections() {
|
|
80
|
+
const d = await this.request("GET", "/schema/collections");
|
|
81
|
+
return d.items;
|
|
82
|
+
}
|
|
83
|
+
async fields(collection) {
|
|
84
|
+
const d = await this.request("GET", `/schema/collections/${encodeURIComponent(collection)}/fields`);
|
|
85
|
+
return d.items ?? d.fields ?? [];
|
|
86
|
+
}
|
|
87
|
+
createCollection(body) {
|
|
88
|
+
return this.request("POST", "/schema/collections", body);
|
|
89
|
+
}
|
|
90
|
+
createField(collection, body) {
|
|
91
|
+
return this.request("POST", `/schema/collections/${encodeURIComponent(collection)}/fields`, body);
|
|
92
|
+
}
|
|
93
|
+
async collectionInfo(slug) {
|
|
94
|
+
const d = await this.request("GET", `/schema/collections/${encodeURIComponent(slug)}`);
|
|
95
|
+
return d.item;
|
|
96
|
+
}
|
|
97
|
+
async *allEntries(collection, locale) {
|
|
98
|
+
let cursor;
|
|
99
|
+
do {
|
|
100
|
+
const qs = new URLSearchParams({ limit: "100" });
|
|
101
|
+
if (cursor)
|
|
102
|
+
qs.set("cursor", cursor);
|
|
103
|
+
if (locale)
|
|
104
|
+
qs.set("locale", locale);
|
|
105
|
+
const d = await this.request("GET", `/content/${encodeURIComponent(collection)}?${qs}`);
|
|
106
|
+
for (const it of d.items)
|
|
107
|
+
yield it;
|
|
108
|
+
cursor = d.nextCursor ?? undefined;
|
|
109
|
+
} while (cursor);
|
|
110
|
+
}
|
|
111
|
+
// Taxonomies
|
|
112
|
+
async taxonomies() {
|
|
113
|
+
const d = await this.request("GET", "/taxonomies");
|
|
114
|
+
return d.taxonomies;
|
|
115
|
+
}
|
|
116
|
+
createTaxonomy(body) {
|
|
117
|
+
return this.request("POST", "/taxonomies", body);
|
|
118
|
+
}
|
|
119
|
+
/** Every term of a taxonomy, flattened (hierarchical taxonomies come back nested under `children`). */
|
|
120
|
+
async terms(taxonomy, locale) {
|
|
121
|
+
const qs = locale ? `?${new URLSearchParams({ locale })}` : "";
|
|
122
|
+
const d = await this.request("GET", `/taxonomies/${encodeURIComponent(taxonomy)}/terms${qs}`);
|
|
123
|
+
const out = [];
|
|
124
|
+
const walk = (list) => {
|
|
125
|
+
for (const t of list) {
|
|
126
|
+
out.push({ id: t.id, slug: t.slug, label: t.label });
|
|
127
|
+
if (t.children?.length)
|
|
128
|
+
walk(t.children);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
walk(d.terms ?? d.items ?? []);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
async createTerm(taxonomy, body) {
|
|
135
|
+
const d = await this.request("POST", `/taxonomies/${encodeURIComponent(taxonomy)}/terms`, body);
|
|
136
|
+
const t = d.item ?? d.term ?? (d.id && d.slug ? { id: d.id, slug: d.slug } : undefined);
|
|
137
|
+
if (!t)
|
|
138
|
+
throw new Error(`Unexpected term response for ${taxonomy}/${String(body.slug)}: ${JSON.stringify(d).slice(0, 200)}`);
|
|
139
|
+
return t;
|
|
140
|
+
}
|
|
141
|
+
// Content
|
|
142
|
+
async findByField(collection, field, value, locale) {
|
|
143
|
+
const qs = new URLSearchParams({ limit: "2", fieldFilters: JSON.stringify({ [field]: value }) });
|
|
144
|
+
if (locale)
|
|
145
|
+
qs.set("locale", locale);
|
|
146
|
+
const d = await this.request("GET", `/content/${encodeURIComponent(collection)}?${qs}`);
|
|
147
|
+
return d.items[0];
|
|
148
|
+
}
|
|
149
|
+
async getEntry(collection, id) {
|
|
150
|
+
const d = await this.request("GET", `/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`);
|
|
151
|
+
return { item: d.item, rev: d._rev };
|
|
152
|
+
}
|
|
153
|
+
async createEntry(collection, body) {
|
|
154
|
+
const d = await this.request("POST", `/content/${encodeURIComponent(collection)}`, body);
|
|
155
|
+
return d.item;
|
|
156
|
+
}
|
|
157
|
+
async updateEntry(collection, id, body) {
|
|
158
|
+
const d = await this.request("PUT", `/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, body);
|
|
159
|
+
return d.item;
|
|
160
|
+
}
|
|
161
|
+
async publishEntry(collection, id) {
|
|
162
|
+
await this.request("POST", `/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/publish`);
|
|
163
|
+
}
|
|
164
|
+
async unpublishEntry(collection, id) {
|
|
165
|
+
await this.request("POST", `/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/unpublish`);
|
|
166
|
+
}
|
|
167
|
+
// Media
|
|
168
|
+
async uploadMedia(bytes, filename, contentType) {
|
|
169
|
+
const form = new FormData();
|
|
170
|
+
form.append("file", new Blob([bytes], { type: contentType }), filename);
|
|
171
|
+
const d = await this.request("POST", "/media", form);
|
|
172
|
+
return { ...d.item, deduplicated: d.deduplicated === true };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { gutenbergToPortableText, htmlToPortableText } from "@emdash-cms/gutenberg-to-portable-text";
|
|
2
|
+
/** WooCommerce descriptions are either Gutenberg block markup or classic HTML. */
|
|
3
|
+
export function toPortableText(html) {
|
|
4
|
+
const trimmed = html.trim();
|
|
5
|
+
if (trimmed === "")
|
|
6
|
+
return [];
|
|
7
|
+
return trimmed.includes("<!-- wp:") ? gutenbergToPortableText(trimmed) : htmlToPortableText(trimmed);
|
|
8
|
+
}
|
|
9
|
+
export function stripHtml(html) {
|
|
10
|
+
return html
|
|
11
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
12
|
+
.replace(/<\/(p|div|li|h[1-6])>/gi, "\n")
|
|
13
|
+
.replace(/<[^>]+>/g, "")
|
|
14
|
+
.replace(/ /g, " ")
|
|
15
|
+
.replace(/&/g, "&")
|
|
16
|
+
.replace(/</g, "<")
|
|
17
|
+
.replace(/>/g, ">")
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/�?39;/g, "'")
|
|
20
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
export function num(v) {
|
|
24
|
+
if (v === null || v === undefined || v === "")
|
|
25
|
+
return undefined;
|
|
26
|
+
const n = typeof v === "number" ? v : Number.parseFloat(v);
|
|
27
|
+
return Number.isFinite(n) ? n : undefined;
|
|
28
|
+
}
|
|
29
|
+
export function int(v) {
|
|
30
|
+
const n = num(v);
|
|
31
|
+
return n === undefined ? undefined : Math.trunc(n);
|
|
32
|
+
}
|
|
33
|
+
/** wc/v3 GMT dates come without a zone suffix, e.g. 2026-03-22T10:11:12. */
|
|
34
|
+
export function gmtDate(v) {
|
|
35
|
+
if (!v)
|
|
36
|
+
return undefined;
|
|
37
|
+
const iso = /Z$|[+-]\d\d:\d\d$/.test(v) ? v : `${v}Z`;
|
|
38
|
+
const d = new Date(iso);
|
|
39
|
+
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
|
|
40
|
+
}
|
|
41
|
+
export function str(v) {
|
|
42
|
+
return v === null || v === undefined || v === "" ? undefined : v;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Language of a product. WC Multilang 1.3.0+ reports it in `wcml_language`;
|
|
46
|
+
* older stores and other plugins fall back to the permalink's first path segment.
|
|
47
|
+
*/
|
|
48
|
+
export function productLanguage(p) {
|
|
49
|
+
if (typeof p.wcml_language === "string" && p.wcml_language !== "")
|
|
50
|
+
return p.wcml_language.toLowerCase();
|
|
51
|
+
return languagePrefix(p.permalink);
|
|
52
|
+
}
|
|
53
|
+
export function languagePrefix(permalink) {
|
|
54
|
+
try {
|
|
55
|
+
const first = new URL(permalink).pathname.split("/").filter(Boolean)[0] ?? "";
|
|
56
|
+
return /^[a-z]{2}(-[a-z]{2})?$/i.test(first) ? first.toLowerCase() : "(none)";
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "(none)";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function put(data, key, value) {
|
|
63
|
+
if (value !== undefined)
|
|
64
|
+
data[key] = value;
|
|
65
|
+
}
|
|
66
|
+
function commerceData(p, units) {
|
|
67
|
+
const d = {};
|
|
68
|
+
put(d, "sku", str(p.sku));
|
|
69
|
+
put(d, "gtin", str(p.global_unique_id));
|
|
70
|
+
put(d, "regular_price", num(p.regular_price));
|
|
71
|
+
put(d, "sale_price", num(p.sale_price));
|
|
72
|
+
put(d, "price", num(p.price));
|
|
73
|
+
put(d, "currency", units.currency);
|
|
74
|
+
put(d, "sale_from", gmtDate(p.date_on_sale_from_gmt));
|
|
75
|
+
put(d, "sale_to", gmtDate(p.date_on_sale_to_gmt));
|
|
76
|
+
put(d, "stock_status", p.stock_status);
|
|
77
|
+
put(d, "manage_stock", p.manage_stock);
|
|
78
|
+
put(d, "stock_quantity", int(p.stock_quantity));
|
|
79
|
+
put(d, "backorders", p.backorders);
|
|
80
|
+
put(d, "low_stock_amount", int(p.low_stock_amount));
|
|
81
|
+
put(d, "weight", num(p.weight));
|
|
82
|
+
put(d, "length", num(p.dimensions?.length));
|
|
83
|
+
put(d, "width", num(p.dimensions?.width));
|
|
84
|
+
put(d, "height", num(p.dimensions?.height));
|
|
85
|
+
put(d, "weight_unit", units.weightUnit);
|
|
86
|
+
put(d, "dimension_unit", units.dimensionUnit);
|
|
87
|
+
put(d, "shipping_class", str(p.shipping_class));
|
|
88
|
+
put(d, "tax_status", p.tax_status);
|
|
89
|
+
put(d, "tax_class", str(p.tax_class));
|
|
90
|
+
put(d, "virtual", p.virtual);
|
|
91
|
+
put(d, "downloadable", p.downloadable);
|
|
92
|
+
if (p.downloads.length > 0)
|
|
93
|
+
d.downloads = p.downloads;
|
|
94
|
+
put(d, "download_limit", int(p.download_limit));
|
|
95
|
+
put(d, "download_expiry", int(p.download_expiry));
|
|
96
|
+
return d;
|
|
97
|
+
}
|
|
98
|
+
export function productData(p, units, attributeTaxonomy, featured, gallery) {
|
|
99
|
+
const d = {
|
|
100
|
+
title: p.name,
|
|
101
|
+
content: toPortableText(p.description),
|
|
102
|
+
product_type: ["simple", "variable", "grouped", "external"].includes(p.type) ? p.type : "simple",
|
|
103
|
+
...commerceData(p, units),
|
|
104
|
+
featured: p.featured,
|
|
105
|
+
catalog_visibility: p.catalog_visibility,
|
|
106
|
+
menu_order: p.menu_order,
|
|
107
|
+
sold_individually: p.sold_individually,
|
|
108
|
+
reviews_allowed: p.reviews_allowed,
|
|
109
|
+
woo_id: p.id,
|
|
110
|
+
woo_permalink: p.permalink,
|
|
111
|
+
};
|
|
112
|
+
put(d, "excerpt", str(stripHtml(p.short_description)));
|
|
113
|
+
put(d, "featured_image", featured);
|
|
114
|
+
if (gallery.length > 0)
|
|
115
|
+
d.gallery = gallery.map((image) => ({ image }));
|
|
116
|
+
put(d, "external_url", str(p.external_url));
|
|
117
|
+
put(d, "button_text", str(p.button_text));
|
|
118
|
+
put(d, "purchase_note", str(stripHtml(p.purchase_note ?? "")));
|
|
119
|
+
put(d, "average_rating", num(p.average_rating));
|
|
120
|
+
put(d, "rating_count", int(p.rating_count));
|
|
121
|
+
if (p.attributes.length > 0) {
|
|
122
|
+
const attrs = p.attributes.map((a) => ({
|
|
123
|
+
name: a.name,
|
|
124
|
+
taxonomy: a.id > 0 && a.slug ? attributeTaxonomy(a.slug) : null,
|
|
125
|
+
woo_taxonomy: a.id > 0 && a.slug ? a.slug : null,
|
|
126
|
+
visible: a.visible,
|
|
127
|
+
variation: a.variation,
|
|
128
|
+
options: a.options,
|
|
129
|
+
}));
|
|
130
|
+
d.attributes = attrs;
|
|
131
|
+
}
|
|
132
|
+
if (p.default_attributes.length > 0)
|
|
133
|
+
d.default_attributes = p.default_attributes;
|
|
134
|
+
put(d, "woo_modified", gmtDate(p.date_modified_gmt));
|
|
135
|
+
return d;
|
|
136
|
+
}
|
|
137
|
+
export function variationData(v, parentTitle, parentEntryId, units, attributeTaxonomy, featured) {
|
|
138
|
+
const options = v.attributes.map((a) => a.option).filter(Boolean);
|
|
139
|
+
const d = {
|
|
140
|
+
title: str(v.name) ?? (options.length ? `${parentTitle} - ${options.join(", ")}` : `${parentTitle} #${v.id}`),
|
|
141
|
+
product: parentEntryId,
|
|
142
|
+
content: toPortableText(v.description),
|
|
143
|
+
...commerceData(v, units),
|
|
144
|
+
menu_order: v.menu_order,
|
|
145
|
+
woo_id: v.id,
|
|
146
|
+
woo_permalink: v.permalink,
|
|
147
|
+
};
|
|
148
|
+
put(d, "featured_image", featured);
|
|
149
|
+
if (v.attributes.length > 0) {
|
|
150
|
+
d.attributes = v.attributes.map((a) => ({
|
|
151
|
+
name: a.name,
|
|
152
|
+
taxonomy: a.id > 0 && a.slug ? attributeTaxonomy(a.slug) : null,
|
|
153
|
+
woo_taxonomy: a.id > 0 && a.slug ? a.slug : null,
|
|
154
|
+
option: a.option,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
put(d, "woo_modified", gmtDate(v.date_modified_gmt));
|
|
158
|
+
return d;
|
|
159
|
+
}
|
|
160
|
+
/** WooCommerce percent-encodes non-ASCII slugs; EmDash wants a plain one. */
|
|
161
|
+
export function entrySlug(wooSlug, fallbackId) {
|
|
162
|
+
let s = wooSlug;
|
|
163
|
+
try {
|
|
164
|
+
s = decodeURIComponent(s);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* keep as is */
|
|
168
|
+
}
|
|
169
|
+
s = s
|
|
170
|
+
.normalize("NFKD")
|
|
171
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
172
|
+
.toLowerCase()
|
|
173
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
174
|
+
.replace(/^-+|-+$/g, "")
|
|
175
|
+
.slice(0, 180);
|
|
176
|
+
return s || `product-${fallbackId}`;
|
|
177
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent writes keyed on the `woo_id` field. Each collection's entries are
|
|
3
|
+
* looked up by indexed field filter, so re-runs update instead of duplicating.
|
|
4
|
+
*/
|
|
5
|
+
export class EntryWriter {
|
|
6
|
+
known = new Map();
|
|
7
|
+
created = 0;
|
|
8
|
+
updated = 0;
|
|
9
|
+
client;
|
|
10
|
+
dryRun;
|
|
11
|
+
locale;
|
|
12
|
+
constructor(client, dryRun, locale) {
|
|
13
|
+
this.client = client;
|
|
14
|
+
this.dryRun = dryRun;
|
|
15
|
+
this.locale = locale;
|
|
16
|
+
}
|
|
17
|
+
entryFor(collection, wooId) {
|
|
18
|
+
return this.known.get(collection)?.get(wooId);
|
|
19
|
+
}
|
|
20
|
+
remember(collection, wooId, item) {
|
|
21
|
+
let m = this.known.get(collection);
|
|
22
|
+
if (!m) {
|
|
23
|
+
m = new Map();
|
|
24
|
+
this.known.set(collection, m);
|
|
25
|
+
}
|
|
26
|
+
m.set(wooId, item);
|
|
27
|
+
}
|
|
28
|
+
/** Lookup ignoring the run's locale, for finding a translation sibling imported in another language. */
|
|
29
|
+
async findAnyLocale(collection, wooId) {
|
|
30
|
+
return this.client.findByField(collection, "woo_id", wooId);
|
|
31
|
+
}
|
|
32
|
+
async find(collection, wooId) {
|
|
33
|
+
const cached = this.entryFor(collection, wooId);
|
|
34
|
+
if (cached)
|
|
35
|
+
return cached;
|
|
36
|
+
const found = await this.client.findByField(collection, "woo_id", wooId, this.locale);
|
|
37
|
+
if (found)
|
|
38
|
+
this.remember(collection, wooId, found);
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Create or update one entry. `publish` mirrors WooCommerce's publish status;
|
|
43
|
+
* everything else lands as a draft.
|
|
44
|
+
*/
|
|
45
|
+
async upsert(opts) {
|
|
46
|
+
const existing = await this.find(opts.collection, opts.wooId);
|
|
47
|
+
if (this.dryRun) {
|
|
48
|
+
const item = existing ?? { id: `dry-run:${opts.collection}:${opts.wooId}`, slug: opts.slug, status: "draft", data: {} };
|
|
49
|
+
if (existing)
|
|
50
|
+
this.updated += 1;
|
|
51
|
+
else
|
|
52
|
+
this.created += 1;
|
|
53
|
+
this.remember(opts.collection, opts.wooId, item);
|
|
54
|
+
return item;
|
|
55
|
+
}
|
|
56
|
+
if (!existing) {
|
|
57
|
+
// Always create as a draft and publish explicitly: the create endpoint's
|
|
58
|
+
// own publish behaviour was not consistent across entries in testing.
|
|
59
|
+
const body = { data: opts.data, slug: opts.slug, status: "draft" };
|
|
60
|
+
if (this.locale)
|
|
61
|
+
body.locale = this.locale;
|
|
62
|
+
if (opts.translationOf)
|
|
63
|
+
body.translationOf = opts.translationOf;
|
|
64
|
+
if (opts.createdAt) {
|
|
65
|
+
body.createdAt = opts.createdAt;
|
|
66
|
+
if (opts.publish)
|
|
67
|
+
body.publishedAt = opts.createdAt;
|
|
68
|
+
}
|
|
69
|
+
if (opts.taxonomies && Object.keys(opts.taxonomies).length > 0)
|
|
70
|
+
body.taxonomies = opts.taxonomies;
|
|
71
|
+
let item;
|
|
72
|
+
try {
|
|
73
|
+
item = await this.client.createEntry(opts.collection, body);
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
// Slug taken by an unrelated entry (multilingual duplicates, earlier manual content): retry with a unique slug.
|
|
77
|
+
if (e instanceof Error && /slug/i.test(e.message)) {
|
|
78
|
+
body.slug = `${opts.slug}-${opts.wooId}`;
|
|
79
|
+
item = await this.client.createEntry(opts.collection, body);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (opts.publish) {
|
|
86
|
+
await this.client.publishEntry(opts.collection, item.id);
|
|
87
|
+
item = { ...item, status: "published" };
|
|
88
|
+
}
|
|
89
|
+
this.created += 1;
|
|
90
|
+
this.remember(opts.collection, opts.wooId, item);
|
|
91
|
+
return item;
|
|
92
|
+
}
|
|
93
|
+
const body = { data: opts.data, skipRevision: true };
|
|
94
|
+
if (this.locale)
|
|
95
|
+
body.locale = this.locale;
|
|
96
|
+
if (existing.slug !== opts.slug && !existing.slug.startsWith(`${opts.slug}-`))
|
|
97
|
+
body.slug = opts.slug;
|
|
98
|
+
if (opts.taxonomies)
|
|
99
|
+
body.taxonomies = opts.taxonomies;
|
|
100
|
+
let item;
|
|
101
|
+
try {
|
|
102
|
+
item = await this.client.updateEntry(opts.collection, existing.id, body);
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (body.slug && e instanceof Error && /slug/i.test(e.message)) {
|
|
106
|
+
delete body.slug;
|
|
107
|
+
item = await this.client.updateEntry(opts.collection, existing.id, body);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (opts.publish)
|
|
114
|
+
await this.client.publishEntry(opts.collection, existing.id).catch(() => undefined);
|
|
115
|
+
if (!opts.publish && existing.status === "published")
|
|
116
|
+
await this.client.unpublishEntry(opts.collection, existing.id).catch(() => undefined);
|
|
117
|
+
this.updated += 1;
|
|
118
|
+
const merged = { ...item, status: opts.publish ? "published" : item.status };
|
|
119
|
+
this.remember(opts.collection, opts.wooId, merged);
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
/** Second-pass update of a few fields (used for cross references once every id is known). */
|
|
123
|
+
async patch(collection, id, data, publish) {
|
|
124
|
+
if (this.dryRun)
|
|
125
|
+
return;
|
|
126
|
+
const body = { data, skipRevision: true };
|
|
127
|
+
if (this.locale)
|
|
128
|
+
body.locale = this.locale;
|
|
129
|
+
await this.client.updateEntry(collection, id, body);
|
|
130
|
+
if (publish)
|
|
131
|
+
await this.client.publishEntry(collection, id).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* After HTML or Gutenberg conversion, image blocks still point at the
|
|
3
|
+
* WooCommerce upload URLs. Upload each one to EmDash media and repoint the
|
|
4
|
+
* block's asset reference at the EmDash media item. Walks nested structures
|
|
5
|
+
* (galleries, columns) generically, so it does not depend on the converter's
|
|
6
|
+
* block catalogue.
|
|
7
|
+
*/
|
|
8
|
+
export async function relinkImages(blocks, media) {
|
|
9
|
+
let relinked = 0;
|
|
10
|
+
const seen = new Map();
|
|
11
|
+
const walk = async (node) => {
|
|
12
|
+
if (Array.isArray(node))
|
|
13
|
+
return Promise.all(node.map(walk));
|
|
14
|
+
if (!node || typeof node !== "object")
|
|
15
|
+
return node;
|
|
16
|
+
const obj = { ...node };
|
|
17
|
+
if (obj._type === "image" && obj.asset && typeof obj.asset === "object") {
|
|
18
|
+
const asset = { ...obj.asset };
|
|
19
|
+
const url = typeof asset.url === "string" ? asset.url : "";
|
|
20
|
+
if (/^https?:\/\//i.test(url)) {
|
|
21
|
+
let p = seen.get(url);
|
|
22
|
+
if (!p) {
|
|
23
|
+
p = media.fromUrl(url, typeof obj.alt === "string" ? obj.alt : "");
|
|
24
|
+
seen.set(url, p);
|
|
25
|
+
}
|
|
26
|
+
const item = await p;
|
|
27
|
+
if (item) {
|
|
28
|
+
asset._ref = item.id;
|
|
29
|
+
if (item.src)
|
|
30
|
+
asset.url = item.src;
|
|
31
|
+
relinked += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
obj.asset = asset;
|
|
35
|
+
}
|
|
36
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
37
|
+
if (k !== "asset" && (Array.isArray(v) || (v && typeof v === "object")))
|
|
38
|
+
obj[k] = await walk(v);
|
|
39
|
+
}
|
|
40
|
+
return obj;
|
|
41
|
+
};
|
|
42
|
+
const out = (await walk(blocks));
|
|
43
|
+
return { blocks: out, relinked };
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { log } from "../log.js";
|
|
2
|
+
/**
|
|
3
|
+
* Downloads WooCommerce images and uploads them to EmDash media. One upload
|
|
4
|
+
* per distinct source URL per run; EmDash itself deduplicates by content hash,
|
|
5
|
+
* so re-runs do not pile up copies either.
|
|
6
|
+
*/
|
|
7
|
+
export class MediaImporter {
|
|
8
|
+
cache = new Map();
|
|
9
|
+
uploaded = 0;
|
|
10
|
+
reused = 0;
|
|
11
|
+
failed = 0;
|
|
12
|
+
client;
|
|
13
|
+
dryRun;
|
|
14
|
+
constructor(client, dryRun) {
|
|
15
|
+
this.client = client;
|
|
16
|
+
this.dryRun = dryRun;
|
|
17
|
+
}
|
|
18
|
+
/** Same as image(), for a bare URL found inside description HTML. */
|
|
19
|
+
fromUrl(src, alt) {
|
|
20
|
+
return this.image({ id: 0, src, name: "", alt });
|
|
21
|
+
}
|
|
22
|
+
async image(img) {
|
|
23
|
+
if (!img?.src)
|
|
24
|
+
return undefined;
|
|
25
|
+
if (this.dryRun)
|
|
26
|
+
return { id: "dry-run", src: img.src, alt: img.alt };
|
|
27
|
+
let p = this.cache.get(img.src);
|
|
28
|
+
if (!p) {
|
|
29
|
+
p = this.fetchAndUpload(img);
|
|
30
|
+
this.cache.set(img.src, p);
|
|
31
|
+
}
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
async fetchAndUpload(img) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(img.src, { headers: { "User-Agent": "woo2emdash/0.0.1" } });
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new Error(`HTTP ${res.status}`);
|
|
39
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
40
|
+
const contentType = res.headers.get("content-type")?.split(";")[0] ?? "application/octet-stream";
|
|
41
|
+
const filename = decodeURIComponent(new URL(img.src).pathname.split("/").pop() || "image") || `image-${img.id}`;
|
|
42
|
+
const item = await this.client.uploadMedia(bytes, filename, contentType);
|
|
43
|
+
if (item.deduplicated)
|
|
44
|
+
this.reused += 1;
|
|
45
|
+
else
|
|
46
|
+
this.uploaded += 1;
|
|
47
|
+
const value = { id: item.id, src: item.url, filename: item.filename, mimeType: item.mimeType };
|
|
48
|
+
if (item.width !== undefined)
|
|
49
|
+
value.width = item.width;
|
|
50
|
+
if (item.height !== undefined)
|
|
51
|
+
value.height = item.height;
|
|
52
|
+
if (img.alt)
|
|
53
|
+
value.alt = img.alt;
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
this.failed += 1;
|
|
58
|
+
log.warn(`image ${img.src}: ${e instanceof Error ? e.message : String(e)}`);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|