kempo-inventory 0.2.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.
Files changed (68) hide show
  1. package/README.md +429 -0
  2. package/admin/categories/index.page.html +229 -0
  3. package/admin/edit/index.page.html +51 -0
  4. package/admin/fields/index.page.html +300 -0
  5. package/admin/index.page.html +203 -0
  6. package/admin/nav.global.html +3 -0
  7. package/admin/new/index.page.html +43 -0
  8. package/install.js +19 -0
  9. package/kempo-config.json +103 -0
  10. package/package.json +59 -0
  11. package/public/api/categories/DELETE.js +12 -0
  12. package/public/api/categories/GET.js +12 -0
  13. package/public/api/categories/PUT.js +20 -0
  14. package/public/api/export/GET.js +31 -0
  15. package/public/api/fields/GET.js +23 -0
  16. package/public/api/fields/POST.js +12 -0
  17. package/public/api/fields/[key]/DELETE.js +11 -0
  18. package/public/api/fields/[key]/PATCH.js +16 -0
  19. package/public/api/fields/[key]/suggestions/GET.js +12 -0
  20. package/public/api/import/apply/POST.js +25 -0
  21. package/public/api/import/preview/POST.js +30 -0
  22. package/public/api/items/GET.js +35 -0
  23. package/public/api/items/POST.js +12 -0
  24. package/public/api/items/[id]/DELETE.js +11 -0
  25. package/public/api/items/[id]/GET.js +15 -0
  26. package/public/api/items/[id]/PATCH.js +12 -0
  27. package/public/api/items/[id]/adjust/POST.js +12 -0
  28. package/public/api/movements/GET.js +17 -0
  29. package/public/api/tags/GET.js +12 -0
  30. package/public/components/BarcodeScanner.js +198 -0
  31. package/public/components/ImageCropDialog.js +94 -0
  32. package/public/components/ImportExport.js +194 -0
  33. package/public/components/ItemDialog.js +135 -0
  34. package/public/components/ItemFields.js +441 -0
  35. package/public/components/ItemForm.js +186 -0
  36. package/public/components/NewItemPage.js +92 -0
  37. package/public/components/RowAction.js +34 -0
  38. package/public/components/flash.js +20 -0
  39. package/public/sdk.js +96 -0
  40. package/public/vendor/LICENSES.md +12 -0
  41. package/public/vendor/ts-custom-error.js +8 -0
  42. package/public/vendor/zxing-browser.js +8 -0
  43. package/public/vendor/zxing-library.js +27 -0
  44. package/sdk.js +65 -0
  45. package/server/db/schema.js +74 -0
  46. package/server/utils/access.js +43 -0
  47. package/server/utils/authorize.js +13 -0
  48. package/server/utils/categories.js +267 -0
  49. package/server/utils/categoryLogic.js +54 -0
  50. package/server/utils/events.js +31 -0
  51. package/server/utils/fieldTypes.js +227 -0
  52. package/server/utils/fields.js +238 -0
  53. package/server/utils/hooks.js +30 -0
  54. package/server/utils/importExport.js +239 -0
  55. package/server/utils/importFile.js +148 -0
  56. package/server/utils/importStage.js +76 -0
  57. package/server/utils/items.js +412 -0
  58. package/server/utils/media.js +245 -0
  59. package/server/utils/mediaSafety.js +51 -0
  60. package/server/utils/ownership.js +44 -0
  61. package/server/utils/rank.js +31 -0
  62. package/server/utils/ratio.js +31 -0
  63. package/server/utils/suggestions.js +36 -0
  64. package/server/utils/tagList.js +25 -0
  65. package/server/utils/tags.js +47 -0
  66. package/server/utils/zip.js +88 -0
  67. package/server/utils/zipRead.js +99 -0
  68. package/update.js +126 -0
@@ -0,0 +1,239 @@
1
+ import db from 'kempo/server/db/index.js';
2
+ import { asc } from 'drizzle-orm';
3
+ import { kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
4
+ import { getFields } from './fields.js';
5
+ import { getItemBySku, createItem, updateItem, adjustStock } from './items.js';
6
+ import { getCategories, saveCategory } from './categories.js';
7
+ import { applicableFields, coerceValues } from './fieldTypes.js';
8
+ import { normalizeTags } from './tags.js';
9
+ import { tidyCategory, categoryKey, MAX_CATEGORY } from './categoryLogic.js';
10
+ import { readMedia } from './media.js';
11
+
12
+ /*
13
+ Exporting and importing an inventory.
14
+
15
+ The export is one JSON document (see FORMAT below), on its own for "data only" or inside a zip together
16
+ with every photo and file it uses ("with media"). Import reads the same document.
17
+
18
+ What it carries: every item (SKU, name, category, quantity, tags, description and the values of
19
+ its custom fields) and every category (description, tags, picture). It also lists the field
20
+ definitions, for reference: an import never creates or changes fields, so fields have to exist
21
+ already for their values to come across. Values for fields that do not exist here, or do not apply to an
22
+ item's category, are ignored and counted.
23
+
24
+ Photo and file fields only travel in the zip version. A field linking to another item is written as
25
+ that item's SKU and resolved by SKU on import.
26
+ */
27
+ export const FORMAT = 'kempo-inventory-export';
28
+ export const VERSION = 1;
29
+ export const MAX_IMPORT_ROWS = 500;
30
+
31
+ const safeName = name => String(name ?? 'file').replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').replace(/^\.+/, '').slice(0, 80) || 'file';
32
+
33
+ /*
34
+ Returns { data, files }: the JSON document and, with \`withMedia\`, the media files it refers to as
35
+ [{ path, bytes }]. Media references in the document are paths within the zip ("media/3-photo.jpg"),
36
+ described in \`data.media\`.
37
+ */
38
+ export const buildExport = async ({ withMedia = false } = {}) => {
39
+ const [fieldsError, fields] = await getFields();
40
+ if(fieldsError) return [fieldsError, null];
41
+
42
+ const itemRows = await db.select().from(kempoInventoryItem).orderBy(asc(kempoInventoryItem.sku));
43
+ const categoryRows = await db.select().from(kempoInventoryCategory).orderBy(asc(kempoInventoryCategory.name));
44
+ const skuById = new Map(itemRows.map(row => [row.id, row.sku]));
45
+
46
+ /* The media each item holds, in the fields that apply to it. */
47
+ const mediaIdsOf = row => applicableFields(fields, row.category)
48
+ .filter(f => f.type === 'media')
49
+ .flatMap(f => (row.data?.[f.key] ?? []));
50
+ const wanted = [];
51
+ if(withMedia){
52
+ for(const row of itemRows) for(const id of mediaIdsOf(row)) if(!wanted.includes(id)) wanted.push(id);
53
+ for(const row of categoryRows) if(row.image && !wanted.includes(row.image)) wanted.push(row.image);
54
+ }
55
+
56
+ const pathOf = new Map();
57
+ const files = [];
58
+ const media = {};
59
+ let missingMedia = 0;
60
+ for(const id of wanted){
61
+ const [error, file] = await readMedia(id);
62
+ if(error){ missingMedia++; continue; }
63
+ const path = `media/${pathOf.size + 1}-${safeName(file.name)}`;
64
+ pathOf.set(id, path);
65
+ files.push({ path, bytes: file.bytes });
66
+ media[path] = { name: file.name, kind: file.kind, alt: file.alt };
67
+ }
68
+
69
+ const items = itemRows.map(row => {
70
+ const values = {};
71
+ for(const field of applicableFields(fields, row.category)){
72
+ const value = row.data?.[field.key];
73
+ if(value === undefined || value === null) continue;
74
+ if(field.type === 'media'){
75
+ const paths = (value ?? []).map(id => pathOf.get(id)).filter(Boolean);
76
+ if(paths.length) values[field.key] = paths;
77
+ } else if(field.type === 'item'){
78
+ const sku = skuById.get(value);
79
+ if(sku) values[field.key] = sku;
80
+ } else {
81
+ values[field.key] = value;
82
+ }
83
+ }
84
+ return { sku: row.sku, name: row.name, category: row.category, quantity: row.quantity, tags: row.tags ?? [], description: row.description, fields: values };
85
+ });
86
+
87
+ const categories = categoryRows.map(row => ({
88
+ name: row.name,
89
+ description: row.description,
90
+ tags: row.tags ?? [],
91
+ ...(row.image && pathOf.has(row.image) ? { image: pathOf.get(row.image) } : {}),
92
+ }));
93
+
94
+ const data = {
95
+ format: FORMAT,
96
+ version: VERSION,
97
+ exportedAt: new Date().toISOString(),
98
+ withMedia,
99
+ fields: fields.map(f => ({ key: f.key, label: f.label, type: f.type, category: f.categoryName ?? '', required: f.required, options: f.options })),
100
+ categories,
101
+ items,
102
+ ...(withMedia ? { media } : {}),
103
+ };
104
+ return [null, { data, files, missingMedia }];
105
+ };
106
+
107
+ const given = value => value !== undefined && value !== null && value !== '' && !(Array.isArray(value) && !value.length);
108
+
109
+ /*
110
+ Applies (or, with \`dryRun\`, only checks) a batch of items and categories from an export.
111
+
112
+ onMatch 'skip' an item or category that already exists (same SKU / same name) is left alone
113
+ 'update' it is updated with the values the file provides; a value the file leaves empty
114
+ is never cleared. A changed quantity is applied as a stock adjustment, so the
115
+ history shows it.
116
+
117
+ Rows are independent: one that fails does not stop the others. Resolves to
118
+ [null, { results, categoryResults, summary }].
119
+ */
120
+ export const importData = async ({ items = [], categories = [], onMatch = 'skip', dryRun = false } = {}, { userId = '', canUpdate = false, canAdjustStock = false, maxRows = MAX_IMPORT_ROWS } = {}) => {
121
+ if(!['skip', 'update'].includes(onMatch)) return [{ code: 400, msg: 'onMatch must be "skip" or "update"' }, null];
122
+ if(!Array.isArray(items) || !Array.isArray(categories)) return [{ code: 400, msg: 'items and categories must be lists' }, null];
123
+ if(items.length > maxRows || categories.length > maxRows) return [{ code: 413, msg: `Send at most ${maxRows} rows at a time` }, null];
124
+ if(onMatch === 'update' && !canUpdate) return [{ code: 403, msg: 'Updating existing items needs the items:update permission' }, null];
125
+
126
+ const [fieldsError, fields] = await getFields();
127
+ if(fieldsError) return [fieldsError, null];
128
+
129
+ const summary = { created: 0, updated: 0, skipped: 0, errors: 0, ignoredValues: 0, categoriesCreated: 0, categoriesUpdated: 0, categoriesSkipped: 0 };
130
+ const results = [];
131
+ const deferredLinks = []; // { result, sku, key, linkSku }: links to an item that does not exist yet
132
+
133
+ const fail = (result, message) => { result.action = 'error'; result.message = message; summary.errors++; return result; };
134
+
135
+ /* Categories first: whether one already exists is decided before this import's own items give it implicit existence. */
136
+ const categoryResults = [];
137
+ const [, existingCategories] = await getCategories({ includeEmpty: true });
138
+ const known = new Set((existingCategories?.categories ?? []).map(c => c.key));
139
+ for(const [index, row] of categories.entries()){
140
+ const name = tidyCategory(row?.name);
141
+ const result = { row: index + 1, name, action: '', message: '' };
142
+ categoryResults.push(result);
143
+ if(!name){ result.action = 'error'; result.message = 'Missing name'; summary.errors++; continue; }
144
+ const exists = known.has(categoryKey(name));
145
+ if(exists && onMatch === 'skip'){ result.action = 'skip'; summary.categoriesSkipped++; continue; }
146
+ result.action = exists ? 'update' : 'create';
147
+ if(dryRun){ exists ? summary.categoriesUpdated++ : summary.categoriesCreated++; continue; }
148
+ const changes = {};
149
+ if(given(row.description)) changes.description = String(row.description);
150
+ if(given(row.tags)) changes.tags = row.tags;
151
+ if(given(row.image)) changes.image = row.image;
152
+ const [error] = await saveCategory(name, changes, { userId });
153
+ if(error){ result.action = 'error'; result.message = error.msg; summary.errors++; continue; }
154
+ known.add(categoryKey(name));
155
+ exists ? summary.categoriesUpdated++ : summary.categoriesCreated++;
156
+ }
157
+
158
+ for(const [index, row] of items.entries()){
159
+ const result = { row: index + 1, sku: String(row?.sku ?? '').trim(), name: String(row?.name ?? '').trim(), action: '', message: '' };
160
+ results.push(result);
161
+ if(!row || typeof row !== 'object') { fail(result, 'Not an item'); continue; }
162
+ if(!result.sku){ fail(result, 'Missing SKU'); continue; }
163
+
164
+ const [, existing] = await getItemBySku(result.sku);
165
+ if(existing && onMatch === 'skip'){ result.action = 'skip'; result.message = 'Already exists'; summary.skipped++; continue; }
166
+
167
+ const category = given(row.category) ? tidyCategory(row.category) : (existing?.category ?? '');
168
+ if(category.length > MAX_CATEGORY){ fail(result, `Category must be ${MAX_CATEGORY} characters or fewer`); continue; }
169
+ let tags;
170
+ if(given(row.tags)){
171
+ const [tagsError, cleanTags] = normalizeTags(row.tags);
172
+ if(tagsError){ fail(result, tagsError.msg); continue; }
173
+ tags = cleanTags;
174
+ }
175
+ let quantity;
176
+ if(given(row.quantity)){
177
+ quantity = Number(row.quantity);
178
+ if(!Number.isInteger(quantity) || quantity < 0){ fail(result, 'Quantity must be a non-negative whole number'); continue; }
179
+ }
180
+ if(!existing && !result.name){ fail(result, 'Missing name'); continue; }
181
+
182
+ /* Custom field values: only fields that exist and apply to the item's category. */
183
+ const applicable = applicableFields(fields, category);
184
+ const byKey = new Map(applicable.map(f => [f.key, f]));
185
+ const values = {};
186
+ const links = [];
187
+ for(const [key, value] of Object.entries(row.fields && typeof row.fields === 'object' ? row.fields : {})){
188
+ const field = byKey.get(key);
189
+ if(!field){ summary.ignoredValues++; continue; }
190
+ if(!given(value)) continue;
191
+ if(field.type === 'item'){
192
+ const [, target] = await getItemBySku(String(value).trim());
193
+ if(target) values[key] = target.id; else links.push({ key, linkSku: String(value).trim() });
194
+ } else {
195
+ values[key] = value;
196
+ }
197
+ }
198
+ const [invalid, coerced] = coerceValues(applicable, values, { partial: Boolean(existing) });
199
+ if(invalid){ fail(result, invalid.msg); continue; }
200
+
201
+ if(existing){
202
+ if(quantity !== undefined && quantity !== existing.quantity && !canAdjustStock){ fail(result, 'Changing a quantity needs the stock:adjust permission'); continue; }
203
+ result.action = 'update';
204
+ if(dryRun){ summary.updated++; continue; }
205
+ const changes = { fields: coerced };
206
+ if(given(row.name)) changes.name = result.name;
207
+ if(given(row.description)) changes.description = String(row.description);
208
+ if(given(row.category)) changes.category = category;
209
+ if(tags !== undefined) changes.tags = tags;
210
+ const [error] = await updateItem(existing.id, { ...changes, userId });
211
+ if(error && !(error.code === 400 && error.msg === 'No changes provided')){ fail(result, error.msg); continue; }
212
+ if(quantity !== undefined && quantity !== existing.quantity){
213
+ const [adjustError] = await adjustStock(existing.id, { delta: quantity - existing.quantity, reason: 'import', note: 'Imported', userId });
214
+ if(adjustError){ fail(result, adjustError.msg); continue; }
215
+ }
216
+ summary.updated++;
217
+ for(const link of links) deferredLinks.push({ result, itemId: existing.id, ...link });
218
+ } else {
219
+ result.action = 'create';
220
+ if(dryRun){ summary.created++; continue; }
221
+ const [error, created] = await createItem({
222
+ sku: result.sku, name: result.name, description: given(row.description) ? String(row.description) : '',
223
+ category, tags: tags ?? [], quantity: quantity ?? 0, fields: coerced, userId,
224
+ });
225
+ if(error){ fail(result, error.msg); continue; }
226
+ summary.created++;
227
+ for(const link of links) deferredLinks.push({ result, itemId: created.id, ...link });
228
+ }
229
+ }
230
+
231
+ /* Links to items created later in the same import. */
232
+ for(const link of deferredLinks){
233
+ const [, target] = await getItemBySku(link.linkSku);
234
+ if(!target){ link.result.message = `${link.result.message ? link.result.message + '; ' : ''}linked item ${link.linkSku} was not found`; continue; }
235
+ await updateItem(link.itemId, { fields: { [link.key]: target.id }, userId });
236
+ }
237
+
238
+ return [null, { results, categoryResults, summary }];
239
+ };
@@ -0,0 +1,148 @@
1
+ import { readZip } from './zipRead.js';
2
+ import { importData, FORMAT } from './importExport.js';
3
+ import { storeMedia } from './media.js';
4
+ import { refuseMedia, safeFileName } from './mediaSafety.js';
5
+
6
+ /*
7
+ Importing an uploaded export (a .json or a .zip), entirely on the server.
8
+
9
+ Nothing here may be reached by anyone but the top tier: the routes check `fields:manage` before they
10
+ read a byte of the upload, and these functions assume it. What they do with the upload:
11
+
12
+ - A zip is recognised by its first bytes, not its name, and read in memory by zipRead.js: entries are
13
+ looked up by the exact names the inventory.json asks for, never written to disk by name, with a
14
+ cap on how many files it may hold and how far one may inflate.
15
+ - A photo reference must have the form "media/<name>" (no folders, no "..", no slashes). Anything else
16
+ is left as it is and the row fails validation, so no name from the zip is ever used as a path.
17
+ - A file is only stored if its extension is a photo, video, audio or PDF type AND its first bytes really
18
+ are that type (mediaSafety.js). Everything else in the zip is skipped and reported.
19
+ - Files are stored through the normal library routines (kempo-files or kempo-media), as the person doing
20
+ the import, so those libraries apply their own rules (untrusted files are never executed).
21
+ */
22
+ export const MAX_ITEMS = 50000;
23
+ export const MAX_MEDIA_FILES = 5000;
24
+ export const MAX_MEDIA_BYTES = 1024 * 1024 * 1024; // all the photos in one import
25
+ const MAX_JSON_BYTES = 100 * 1024 * 1024;
26
+ const MEDIA_PATH = /^media\/[^/\\\u0000]{1,200}$/;
27
+
28
+ const isZip = bytes => bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4B && ((bytes[2] === 3 && bytes[3] === 4) || (bytes[2] === 5 && bytes[3] === 6));
29
+
30
+ const isMediaPaths = value => Array.isArray(value) && value.length > 0 && value.every(v => typeof v === 'string' && MEDIA_PATH.test(v));
31
+
32
+ /* Opens the upload: { data, zip } or an { code, msg } fit to show a person. */
33
+ export const parseImportFile = async bytes => {
34
+ let zip = null;
35
+ let text;
36
+ try {
37
+ if(isZip(bytes)){
38
+ zip = await readZip(bytes);
39
+ text = await zip.text('inventory.json');
40
+ if(text === null) return [{ code: 400, msg: 'This zip has no inventory.json. Is it an export from this inventory?' }, null];
41
+ } else {
42
+ if(bytes.length > MAX_JSON_BYTES) return [{ code: 413, msg: 'This file is too large to import' }, null];
43
+ text = bytes.toString('utf8');
44
+ }
45
+ } catch(error) {
46
+ return [{ code: 400, msg: error.message || 'This zip file could not be read' }, null];
47
+ }
48
+ if(text.length > MAX_JSON_BYTES) return [{ code: 413, msg: 'inventory.json is too large to import' }, null];
49
+ let data;
50
+ try {
51
+ data = JSON.parse(text);
52
+ } catch {
53
+ return [{ code: 400, msg: 'This file is not valid JSON.' }, null];
54
+ }
55
+ if(Array.isArray(data)) data = { items: data };
56
+ if(!data || typeof data !== 'object' || !Array.isArray(data.items)) return [{ code: 400, msg: 'This file does not look like an inventory export (it has no list of items).' }, null];
57
+ if(data.format && data.format !== FORMAT) return [{ code: 400, msg: 'This file is not an inventory export.' }, null];
58
+ if(data.items.length > MAX_ITEMS) return [{ code: 413, msg: `This file has more than ${MAX_ITEMS} items` }, null];
59
+ if(data.categories !== undefined && !Array.isArray(data.categories)) return [{ code: 400, msg: 'categories must be a list' }, null];
60
+ return [null, { data: { ...data, categories: data.categories ?? [] }, zip }];
61
+ };
62
+
63
+ /* The rows without their photo references, which cannot be checked until the photos are stored. */
64
+ const withoutMedia = ({ items, categories }) => ({
65
+ items: items.map(item => ({ ...item, fields: Object.fromEntries(Object.entries(item?.fields && typeof item.fields === 'object' ? item.fields : {}).filter(([, value]) => !isMediaPaths(value))) })),
66
+ categories: categories.map(row => { const { image, ...rest } = row ?? {}; return rest; }),
67
+ });
68
+
69
+ const mediaIn = ({ items, categories }) => {
70
+ const paths = new Set();
71
+ for(const item of items) for(const value of Object.values(item?.fields ?? {})) if(isMediaPaths(value)) value.forEach(p => paths.add(p));
72
+ for(const category of categories) if(typeof category?.image === 'string' && MEDIA_PATH.test(category.image)) paths.add(category.image);
73
+ return paths;
74
+ };
75
+
76
+ const PERMISSIONS = { canUpdate: true, canAdjustStock: true, maxRows: MAX_ITEMS }; // the caller is the top tier, which holds all of these
77
+
78
+ /* What importing this file would do, changing nothing: { results, categoryResults, summary, media }. */
79
+ export const previewImport = async (bytes, { userId }) => {
80
+ const [parseError, parsed] = await parseImportFile(bytes);
81
+ if(parseError) return [parseError, null];
82
+ const [error, report] = await importData({ ...withoutMedia(parsed.data), onMatch: 'update', dryRun: true }, { userId, ...PERMISSIONS });
83
+ if(error) return [error, null];
84
+ const paths = mediaIn(parsed.data);
85
+ return [null, {
86
+ ...report,
87
+ counts: { items: parsed.data.items.length, categories: parsed.data.categories.length },
88
+ withMedia: Boolean(parsed.zip),
89
+ mediaFiles: paths.size,
90
+ }];
91
+ };
92
+
93
+ /* Does the import: stores the photos the rows to be written need, then writes the rows. */
94
+ export const applyImport = async (bytes, { onMatch, userId }) => {
95
+ const [parseError, parsed] = await parseImportFile(bytes);
96
+ if(parseError) return [parseError, null];
97
+ const { data, zip } = parsed;
98
+ const items = structuredClone(data.items);
99
+ const categories = structuredClone(data.categories);
100
+
101
+ const stored = new Map(); // path -> stored id, or null when the file was refused
102
+ const skippedFiles = [];
103
+ let uploaded = 0;
104
+ let uploadedBytes = 0;
105
+ const idFor = async path => {
106
+ if(stored.has(path)) return stored.get(path);
107
+ stored.set(path, null);
108
+ const name = safeFileName(data.media?.[path]?.name || path.split('/').pop());
109
+ const file = zip ? await zip.read(path).catch(error => error) : null;
110
+ if(file instanceof Error){ skippedFiles.push({ name, reason: file.message }); return null; }
111
+ if(!file){ skippedFiles.push({ name, reason: 'not in the zip' }); return null; }
112
+ const reason = refuseMedia(name, file);
113
+ if(reason){ skippedFiles.push({ name, reason }); return null; }
114
+ if(uploaded >= MAX_MEDIA_FILES || uploadedBytes + file.length > MAX_MEDIA_BYTES){ skippedFiles.push({ name, reason: 'over the size limit for one import' }); return null; }
115
+ const [error, id] = await storeMedia({ name, bytes: file, altText: data.media?.[path]?.alt ?? '', userId });
116
+ if(error){ skippedFiles.push({ name, reason: error.msg }); return null; }
117
+ uploaded++;
118
+ uploadedBytes += file.length;
119
+ stored.set(path, id);
120
+ return id;
121
+ };
122
+
123
+ /* Photos only for the rows that will actually be created or updated. */
124
+ const [planError, plan] = await importData({ ...withoutMedia({ items, categories }), onMatch, dryRun: true }, { userId, ...PERMISSIONS });
125
+ if(planError) return [planError, null];
126
+ const wanted = new Set(plan.results.filter(r => r.action === 'create' || r.action === 'update').map(r => r.row));
127
+ const wantedCategories = new Set(plan.categoryResults.filter(r => r.action === 'create' || r.action === 'update').map(r => r.row));
128
+
129
+ for(const [index, item] of items.entries()){
130
+ if(!item || typeof item.fields !== 'object') continue;
131
+ for(const [key, value] of Object.entries(item.fields)){
132
+ if(!isMediaPaths(value)) continue;
133
+ if(!wanted.has(index + 1)){ delete item.fields[key]; continue; }
134
+ const ids = [];
135
+ for(const path of value){ const id = await idFor(path); if(id) ids.push(id); }
136
+ if(ids.length) item.fields[key] = ids; else delete item.fields[key];
137
+ }
138
+ }
139
+ for(const [index, category] of categories.entries()){
140
+ if(typeof category?.image !== 'string') continue;
141
+ const id = MEDIA_PATH.test(category.image) && wantedCategories.has(index + 1) ? await idFor(category.image) : null;
142
+ if(id) category.image = id; else delete category.image;
143
+ }
144
+
145
+ const [error, report] = await importData({ items, categories, onMatch, dryRun: false }, { userId, ...PERMISSIONS });
146
+ if(error) return [error, null];
147
+ return [null, { ...report, uploaded, skippedFiles }];
148
+ };
@@ -0,0 +1,76 @@
1
+ import { mkdir, writeFile, readFile, readdir, rm, stat } from 'fs/promises';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import crypto from 'crypto';
5
+
6
+ /*
7
+ Holds an uploaded import file between the preview and the import, so the person uploads it once.
8
+
9
+ Only a top-tier user ever gets here (the routes check first). The file is written under the system's
10
+ temp directory with a random 128-bit id as its whole name, readable only by the server's own user. It is
11
+ never served, never opened by its content's names, and only the person who staged it can use it. It is
12
+ deleted when the import runs, when they stage another, or after 30 minutes.
13
+ */
14
+ const DIR = () => join(tmpdir(), 'kempo-inventory-import');
15
+ const TTL_MS = 30 * 60 * 1000;
16
+ const MAX_PER_USER = 2;
17
+ const ID = /^[a-f0-9]{32}$/;
18
+
19
+ const metaPath = id => join(DIR(), `${id}.json`);
20
+ const dataPath = id => join(DIR(), `${id}.bin`);
21
+
22
+ const remove = async id => {
23
+ await rm(dataPath(id), { force: true }).catch(() => {});
24
+ await rm(metaPath(id), { force: true }).catch(() => {});
25
+ };
26
+
27
+ const readMeta = async id => {
28
+ try {
29
+ return JSON.parse(await readFile(metaPath(id), 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+ };
34
+
35
+ /* Deletes everything expired, and a user's oldest files beyond the limit. */
36
+ export const sweep = async (userId = null, now = Date.now()) => {
37
+ let names;
38
+ try { names = await readdir(DIR()); } catch { return; }
39
+ const mine = [];
40
+ for(const name of names){
41
+ const match = /^([a-f0-9]{32})\.json$/.exec(name);
42
+ if(!match) continue;
43
+ const meta = await readMeta(match[1]);
44
+ if(!meta || now - meta.createdAt > TTL_MS) await remove(match[1]);
45
+ else if(userId && meta.userId === userId) mine.push({ id: match[1], createdAt: meta.createdAt });
46
+ }
47
+ mine.sort((a, b) => b.createdAt - a.createdAt);
48
+ for(const old of mine.slice(MAX_PER_USER - 1)) await remove(old.id);
49
+ };
50
+
51
+ /* Stores the bytes for `userId` and returns the id to use for the import. */
52
+ export const stage = async (bytes, userId, name = '') => {
53
+ await mkdir(DIR(), { recursive: true, mode: 0o700 });
54
+ await sweep(userId);
55
+ const id = crypto.randomBytes(16).toString('hex');
56
+ await writeFile(dataPath(id), bytes, { mode: 0o600 });
57
+ await writeFile(metaPath(id), JSON.stringify({ userId, name: String(name).slice(0, 200), createdAt: Date.now() }), { mode: 0o600 });
58
+ return id;
59
+ };
60
+
61
+ /* The staged bytes, or null when the id is malformed, unknown, expired, or belongs to someone else. */
62
+ export const readStaged = async (id, userId) => {
63
+ if(typeof id !== 'string' || !ID.test(id)) return null;
64
+ const meta = await readMeta(id);
65
+ if(!meta || meta.userId !== userId || Date.now() - meta.createdAt > TTL_MS) return null;
66
+ try {
67
+ await stat(dataPath(id));
68
+ return { bytes: await readFile(dataPath(id)), name: meta.name };
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ export const discardStaged = async id => {
75
+ if(typeof id === 'string' && ID.test(id)) await remove(id);
76
+ };