@porulle/import-flat 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/README.md +44 -0
- package/dist/src/index.d.ts +49 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +154 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +49 -0
- package/src/index.ts +226 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @porulle/import-flat
|
|
2
|
+
|
|
3
|
+
Import catalog entities from a flat JSON file. The lowest-friction format — define an array of products, run `porulle import`, done.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
porulle import ./catalog.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Format:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
[
|
|
15
|
+
{
|
|
16
|
+
"type": "product",
|
|
17
|
+
"slug": "ceylon-black-tea-50g",
|
|
18
|
+
"attributes": {
|
|
19
|
+
"locale": "en",
|
|
20
|
+
"title": "Ceylon Black Tea (50g)",
|
|
21
|
+
"description": "Single-estate, hand-picked.",
|
|
22
|
+
"subtitle": "Uva region"
|
|
23
|
+
},
|
|
24
|
+
"metadata": { "weight": 50, "material": "tea leaves" },
|
|
25
|
+
"variants": [
|
|
26
|
+
{ "sku": "CBT-50G", "options": { "size": "50g" }, "price": 1200 }
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## When to use
|
|
33
|
+
|
|
34
|
+
- Seeding a new store with a small fixed catalog
|
|
35
|
+
- Internal inventory dumps converted from a spreadsheet (export CSV → flatten to JSON)
|
|
36
|
+
- Test fixtures
|
|
37
|
+
- Anything that doesn't have a Shopify or WooCommerce origin
|
|
38
|
+
|
|
39
|
+
For Shopify migrations: `@porulle/import-shopify`. For WooCommerce: `@porulle/import-woocommerce`.
|
|
40
|
+
|
|
41
|
+
## See also
|
|
42
|
+
|
|
43
|
+
- `@porulle/cli` — `porulle import` command
|
|
44
|
+
- `apps/store-example/src/scripts/seed.ts` — programmatic seeding example
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Result } from "@porulle/core";
|
|
2
|
+
export interface FlatImportEntityInput {
|
|
3
|
+
type: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
attributes: {
|
|
6
|
+
locale?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
subtitle?: string;
|
|
10
|
+
};
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
customFields?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface FlatImportTarget {
|
|
15
|
+
createEntity(input: FlatImportEntityInput): Promise<{
|
|
16
|
+
id: string;
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export type FlatValueResolver = string | ((row: Record<string, string>) => unknown);
|
|
20
|
+
export interface FlatImportMapping {
|
|
21
|
+
entityType: string;
|
|
22
|
+
slug: FlatValueResolver;
|
|
23
|
+
title: FlatValueResolver;
|
|
24
|
+
description?: FlatValueResolver;
|
|
25
|
+
subtitle?: FlatValueResolver;
|
|
26
|
+
locale?: string;
|
|
27
|
+
metadata?: Record<string, FlatValueResolver>;
|
|
28
|
+
customFields?: Record<string, FlatValueResolver>;
|
|
29
|
+
}
|
|
30
|
+
export interface ImportFlatOptions {
|
|
31
|
+
mapping: FlatImportMapping;
|
|
32
|
+
target: FlatImportTarget;
|
|
33
|
+
rows?: Array<Record<string, string>>;
|
|
34
|
+
csv?: string;
|
|
35
|
+
json?: string;
|
|
36
|
+
delimiter?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ImportFlatSummary {
|
|
39
|
+
imported: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
errors: Array<{
|
|
42
|
+
row: number;
|
|
43
|
+
message: string;
|
|
44
|
+
}>;
|
|
45
|
+
entityIds: string[];
|
|
46
|
+
}
|
|
47
|
+
export declare function parseCsv(input: string, delimiter?: string): Array<Record<string, string>>;
|
|
48
|
+
export declare function importFlat(options: ImportFlatOptions): Promise<Result<ImportFlatSummary>>;
|
|
49
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAErD,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,gBAAgB;IAC/B,YAAY,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACrE;AAED,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,CAAC;AAEpF,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,iBAAiB,CAAC;IACxB,KAAK,EAAE,iBAAiB,CAAC;IACzB,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IAC7C,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CAClD;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,iBAAiB,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,IAAI,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACrC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChD,SAAS,EAAE,MAAM,EAAE,CAAC;CACrB;AAiCD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,SAAM,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAyBtF;AAqFD,wBAAsB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,CAoC/F"}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Err, Ok } from "@porulle/core";
|
|
2
|
+
function parseCsvLine(line, delimiter) {
|
|
3
|
+
const output = [];
|
|
4
|
+
let current = "";
|
|
5
|
+
let inQuotes = false;
|
|
6
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
7
|
+
const char = line[i];
|
|
8
|
+
if (char === '"') {
|
|
9
|
+
const next = line[i + 1];
|
|
10
|
+
if (inQuotes && next === '"') {
|
|
11
|
+
current += '"';
|
|
12
|
+
i += 1;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
inQuotes = !inQuotes;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (char === delimiter && !inQuotes) {
|
|
19
|
+
output.push(current.trim());
|
|
20
|
+
current = "";
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
current += char;
|
|
24
|
+
}
|
|
25
|
+
output.push(current.trim());
|
|
26
|
+
return output;
|
|
27
|
+
}
|
|
28
|
+
export function parseCsv(input, delimiter = ",") {
|
|
29
|
+
const lines = input
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
if (lines.length === 0)
|
|
34
|
+
return [];
|
|
35
|
+
const headers = parseCsvLine(lines[0], delimiter);
|
|
36
|
+
const rows = [];
|
|
37
|
+
for (const line of lines.slice(1)) {
|
|
38
|
+
const values = parseCsvLine(line, delimiter);
|
|
39
|
+
const row = {};
|
|
40
|
+
for (let i = 0; i < headers.length; i += 1) {
|
|
41
|
+
const key = headers[i];
|
|
42
|
+
if (!key)
|
|
43
|
+
continue;
|
|
44
|
+
row[key] = values[i] ?? "";
|
|
45
|
+
}
|
|
46
|
+
rows.push(row);
|
|
47
|
+
}
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
function resolveValue(row, resolver) {
|
|
51
|
+
if (!resolver)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (typeof resolver === "function")
|
|
54
|
+
return resolver(row);
|
|
55
|
+
return row[resolver];
|
|
56
|
+
}
|
|
57
|
+
function mapRow(row, mapping) {
|
|
58
|
+
const slug = String(resolveValue(row, mapping.slug) ?? "").trim();
|
|
59
|
+
const title = String(resolveValue(row, mapping.title) ?? "").trim();
|
|
60
|
+
const metadata = mapping.metadata
|
|
61
|
+
? Object.fromEntries(Object.entries(mapping.metadata)
|
|
62
|
+
.map(([key, resolver]) => [key, resolveValue(row, resolver)])
|
|
63
|
+
.filter(([, value]) => value !== undefined && value !== ""))
|
|
64
|
+
: undefined;
|
|
65
|
+
const customFields = mapping.customFields
|
|
66
|
+
? Object.fromEntries(Object.entries(mapping.customFields)
|
|
67
|
+
.map(([key, resolver]) => [key, resolveValue(row, resolver)])
|
|
68
|
+
.filter(([, value]) => value !== undefined && value !== ""))
|
|
69
|
+
: undefined;
|
|
70
|
+
return {
|
|
71
|
+
type: mapping.entityType,
|
|
72
|
+
slug,
|
|
73
|
+
attributes: {
|
|
74
|
+
title,
|
|
75
|
+
...(mapping.locale ? { locale: mapping.locale } : {}),
|
|
76
|
+
...(resolveValue(row, mapping.description)
|
|
77
|
+
? { description: String(resolveValue(row, mapping.description)) }
|
|
78
|
+
: {}),
|
|
79
|
+
...(resolveValue(row, mapping.subtitle)
|
|
80
|
+
? { subtitle: String(resolveValue(row, mapping.subtitle)) }
|
|
81
|
+
: {}),
|
|
82
|
+
},
|
|
83
|
+
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
|
|
84
|
+
...(customFields && Object.keys(customFields).length > 0 ? { customFields } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function normalizeRows(options) {
|
|
88
|
+
if (options.rows && options.rows.length > 0)
|
|
89
|
+
return Ok(options.rows);
|
|
90
|
+
if (options.csv) {
|
|
91
|
+
return Ok(parseCsv(options.csv, options.delimiter ?? ","));
|
|
92
|
+
}
|
|
93
|
+
if (options.json) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(options.json);
|
|
96
|
+
if (!Array.isArray(parsed)) {
|
|
97
|
+
return Err({
|
|
98
|
+
code: "IMPORT_FLAT_INVALID_JSON",
|
|
99
|
+
message: "JSON payload must be an array of records.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const rows = parsed.map((value) => {
|
|
103
|
+
if (!value || typeof value !== "object")
|
|
104
|
+
return {};
|
|
105
|
+
return Object.fromEntries(Object.entries(value).map(([key, current]) => [key, String(current ?? "")]));
|
|
106
|
+
});
|
|
107
|
+
return Ok(rows);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
return Err({
|
|
111
|
+
code: "IMPORT_FLAT_INVALID_JSON",
|
|
112
|
+
message: error instanceof Error ? error.message : "Invalid JSON payload.",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return Err({
|
|
117
|
+
code: "IMPORT_FLAT_NO_INPUT",
|
|
118
|
+
message: "Provide rows, csv, or json input for flat import.",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
export async function importFlat(options) {
|
|
122
|
+
const normalized = normalizeRows(options);
|
|
123
|
+
if (!normalized.ok)
|
|
124
|
+
return normalized;
|
|
125
|
+
const summary = {
|
|
126
|
+
imported: 0,
|
|
127
|
+
failed: 0,
|
|
128
|
+
errors: [],
|
|
129
|
+
entityIds: [],
|
|
130
|
+
};
|
|
131
|
+
for (let index = 0; index < normalized.value.length; index += 1) {
|
|
132
|
+
try {
|
|
133
|
+
const row = normalized.value[index];
|
|
134
|
+
const mapped = mapRow(row, options.mapping);
|
|
135
|
+
if (!mapped.slug) {
|
|
136
|
+
throw new Error("Missing slug after mapping.");
|
|
137
|
+
}
|
|
138
|
+
if (!mapped.attributes.title) {
|
|
139
|
+
throw new Error("Missing title after mapping.");
|
|
140
|
+
}
|
|
141
|
+
const created = await options.target.createEntity(mapped);
|
|
142
|
+
summary.imported += 1;
|
|
143
|
+
summary.entityIds.push(created.id);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
summary.failed += 1;
|
|
147
|
+
summary.errors.push({
|
|
148
|
+
row: index + 1,
|
|
149
|
+
message: error instanceof Error ? error.message : "Unknown import error.",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return Ok(summary);
|
|
154
|
+
}
|