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.
- package/README.md +429 -0
- package/admin/categories/index.page.html +229 -0
- package/admin/edit/index.page.html +51 -0
- package/admin/fields/index.page.html +300 -0
- package/admin/index.page.html +203 -0
- package/admin/nav.global.html +3 -0
- package/admin/new/index.page.html +43 -0
- package/install.js +19 -0
- package/kempo-config.json +103 -0
- package/package.json +59 -0
- package/public/api/categories/DELETE.js +12 -0
- package/public/api/categories/GET.js +12 -0
- package/public/api/categories/PUT.js +20 -0
- package/public/api/export/GET.js +31 -0
- package/public/api/fields/GET.js +23 -0
- package/public/api/fields/POST.js +12 -0
- package/public/api/fields/[key]/DELETE.js +11 -0
- package/public/api/fields/[key]/PATCH.js +16 -0
- package/public/api/fields/[key]/suggestions/GET.js +12 -0
- package/public/api/import/apply/POST.js +25 -0
- package/public/api/import/preview/POST.js +30 -0
- package/public/api/items/GET.js +35 -0
- package/public/api/items/POST.js +12 -0
- package/public/api/items/[id]/DELETE.js +11 -0
- package/public/api/items/[id]/GET.js +15 -0
- package/public/api/items/[id]/PATCH.js +12 -0
- package/public/api/items/[id]/adjust/POST.js +12 -0
- package/public/api/movements/GET.js +17 -0
- package/public/api/tags/GET.js +12 -0
- package/public/components/BarcodeScanner.js +198 -0
- package/public/components/ImageCropDialog.js +94 -0
- package/public/components/ImportExport.js +194 -0
- package/public/components/ItemDialog.js +135 -0
- package/public/components/ItemFields.js +441 -0
- package/public/components/ItemForm.js +186 -0
- package/public/components/NewItemPage.js +92 -0
- package/public/components/RowAction.js +34 -0
- package/public/components/flash.js +20 -0
- package/public/sdk.js +96 -0
- package/public/vendor/LICENSES.md +12 -0
- package/public/vendor/ts-custom-error.js +8 -0
- package/public/vendor/zxing-browser.js +8 -0
- package/public/vendor/zxing-library.js +27 -0
- package/sdk.js +65 -0
- package/server/db/schema.js +74 -0
- package/server/utils/access.js +43 -0
- package/server/utils/authorize.js +13 -0
- package/server/utils/categories.js +267 -0
- package/server/utils/categoryLogic.js +54 -0
- package/server/utils/events.js +31 -0
- package/server/utils/fieldTypes.js +227 -0
- package/server/utils/fields.js +238 -0
- package/server/utils/hooks.js +30 -0
- package/server/utils/importExport.js +239 -0
- package/server/utils/importFile.js +148 -0
- package/server/utils/importStage.js +76 -0
- package/server/utils/items.js +412 -0
- package/server/utils/media.js +245 -0
- package/server/utils/mediaSafety.js +51 -0
- package/server/utils/ownership.js +44 -0
- package/server/utils/rank.js +31 -0
- package/server/utils/ratio.js +31 -0
- package/server/utils/suggestions.js +36 -0
- package/server/utils/tagList.js +25 -0
- package/server/utils/tags.js +47 -0
- package/server/utils/zip.js +88 -0
- package/server/utils/zipRead.js +99 -0
- package/update.js +126 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
What an import will accept as a photo or file from a zip. Pure, so it can be tested without a server.
|
|
3
|
+
|
|
4
|
+
A name that ends in .png says nothing about what the bytes are, and a zip comes from whoever made it,
|
|
5
|
+
so a file is only accepted when BOTH hold: its extension is one of the photo, video, audio and PDF
|
|
6
|
+
types the media libraries take, and the first bytes of the file really are that type. A web page,
|
|
7
|
+
script or program renamed to .png (or any extension not listed) is refused.
|
|
8
|
+
*/
|
|
9
|
+
const SIGNATURES = {
|
|
10
|
+
png: bytes => startsWith(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
|
|
11
|
+
jpg: bytes => startsWith(bytes, [0xFF, 0xD8, 0xFF]),
|
|
12
|
+
gif: bytes => startsWith(bytes, [0x47, 0x49, 0x46, 0x38]),
|
|
13
|
+
webp: bytes => ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WEBP',
|
|
14
|
+
bmp: bytes => ascii(bytes, 0, 2) === 'BM',
|
|
15
|
+
avif: bytes => ascii(bytes, 4, 8) === 'ftyp',
|
|
16
|
+
mp4: bytes => ascii(bytes, 4, 8) === 'ftyp',
|
|
17
|
+
mov: bytes => ascii(bytes, 4, 8) === 'ftyp' || ascii(bytes, 4, 8) === 'moov' || ascii(bytes, 4, 8) === 'wide',
|
|
18
|
+
m4a: bytes => ascii(bytes, 4, 8) === 'ftyp',
|
|
19
|
+
webm: bytes => startsWith(bytes, [0x1A, 0x45, 0xDF, 0xA3]),
|
|
20
|
+
mp3: bytes => ascii(bytes, 0, 3) === 'ID3' || (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0),
|
|
21
|
+
wav: bytes => ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 12) === 'WAVE',
|
|
22
|
+
ogg: bytes => ascii(bytes, 0, 4) === 'OggS',
|
|
23
|
+
pdf: bytes => ascii(bytes, 0, 5) === '%PDF-',
|
|
24
|
+
};
|
|
25
|
+
const ALIASES = { jpeg: 'jpg' };
|
|
26
|
+
|
|
27
|
+
const ascii = (bytes, from, to) => String.fromCharCode(...bytes.subarray(from, to));
|
|
28
|
+
const startsWith = (bytes, signature) => signature.every((byte, i) => bytes[i] === byte);
|
|
29
|
+
|
|
30
|
+
export const ALLOWED_EXTENSIONS = Object.keys(SIGNATURES).concat(Object.keys(ALIASES));
|
|
31
|
+
|
|
32
|
+
export const extensionOf = name => {
|
|
33
|
+
const match = /\.([A-Za-z0-9]+)$/.exec(String(name ?? ''));
|
|
34
|
+
return match ? match[1].toLowerCase() : '';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/* Returns null when the file is acceptable, otherwise a short reason it is not. */
|
|
38
|
+
export const refuseMedia = (name, bytes) => {
|
|
39
|
+
const extension = ALIASES[extensionOf(name)] ?? extensionOf(name);
|
|
40
|
+
const check = SIGNATURES[extension];
|
|
41
|
+
if(!check) return 'not a photo, video, audio or PDF file';
|
|
42
|
+
if(!bytes?.length) return 'empty';
|
|
43
|
+
if(!check(bytes)) return `its content is not a real .${extension} file`;
|
|
44
|
+
return null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/* A name safe to hand to a file library: no path, no odd characters, a sensible length. */
|
|
48
|
+
export const safeFileName = (name, fallback = 'file') => {
|
|
49
|
+
const base = String(name ?? '').split(/[\\/]/).pop().replace(/[^A-Za-z0-9._ ()-]+/g, '_').replace(/^[.\s]+/, '').slice(0, 100);
|
|
50
|
+
return base || fallback;
|
|
51
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { tidyCategory } from './categoryLogic.js';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Who may change what. `owner` is '' for things people manage in the admin, or the name of the
|
|
5
|
+
extension that created it, and `actor` is whoever is making the change in the same terms: the
|
|
6
|
+
HTTP layer never names an actor ('' = a person), only an extension calling the server SDK can.
|
|
7
|
+
|
|
8
|
+
Ownership never changes what stock does: anyone with the permission can still adjust an owned
|
|
9
|
+
item's quantity. It protects what the owner builds on:
|
|
10
|
+
|
|
11
|
+
items sku, name, category and deletion belong to the owner
|
|
12
|
+
categories renaming (which rewrites every item's category) and deletion belong to the owner
|
|
13
|
+
fields see fields.js
|
|
14
|
+
|
|
15
|
+
Everything else (description, tags, images, custom field values) stays editable by anyone, so an
|
|
16
|
+
extension's items still show up and can be tidied in the admin. An extension that needs to guard
|
|
17
|
+
more than this subscribes to the before_* hooks.
|
|
18
|
+
*/
|
|
19
|
+
export const LOCKED_ITEM_PROPERTIES = ['sku', 'name', 'category'];
|
|
20
|
+
|
|
21
|
+
export const ownerLabel = owner => owner ? `the "${owner}" extension` : 'people using the admin';
|
|
22
|
+
|
|
23
|
+
/* The locked properties `changes` would actually change. Sending the current value back is not a change. */
|
|
24
|
+
export const lockedItemChanges = (existing, changes, actor = '') => existing.owner === actor ? [] : LOCKED_ITEM_PROPERTIES.filter(property => {
|
|
25
|
+
const value = changes[property];
|
|
26
|
+
if(value === undefined) return false;
|
|
27
|
+
return (property === 'category' ? tidyCategory(value) : String(value).trim()) !== existing[property];
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const notYours = (what, existing, actor = '') => ({
|
|
31
|
+
code: 403,
|
|
32
|
+
msg: `This ${what} is managed by ${ownerLabel(existing.owner)}${actor ? `, not "${actor}"` : ''}`,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
Whether `actor` may rename or delete a record `owner` owns, and may merge into one.
|
|
37
|
+
*/
|
|
38
|
+
export const mayManage = (record, actor = '') => !record || record.owner === actor;
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
Renaming a category rewrites the category on its items and moves its fields, so it must not
|
|
42
|
+
pull the rug from an extension that owns any of them. `holders` are the owners found there.
|
|
43
|
+
*/
|
|
44
|
+
export const blockingOwner = (holders, actor = '') => holders.find(owner => owner && owner !== actor) ?? null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const MAX = 20;
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Ranks values for a typed prefix: ones that start with it first, then the most used, then
|
|
5
|
+
alphabetically. Values that differ only in case ("vallejo", "Vallejo") are one suggestion, shown
|
|
6
|
+
the way it is most often written. Pure, so it can be tested without a database.
|
|
7
|
+
*/
|
|
8
|
+
export const rankSuggestions = (rows, term = '', limit = 8) => {
|
|
9
|
+
const needle = term.trim().toLowerCase();
|
|
10
|
+
const merged = new Map();
|
|
11
|
+
for(const { value, uses } of rows){
|
|
12
|
+
const key = value.toLowerCase();
|
|
13
|
+
const entry = merged.get(key) ?? { total: 0, spellings: new Map() };
|
|
14
|
+
entry.total += uses;
|
|
15
|
+
entry.spellings.set(value, (entry.spellings.get(value) ?? 0) + uses);
|
|
16
|
+
merged.set(key, entry);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return [...merged.entries()]
|
|
20
|
+
.map(([key, entry]) => ({
|
|
21
|
+
key,
|
|
22
|
+
value: [...entry.spellings.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0][0],
|
|
23
|
+
uses: entry.total,
|
|
24
|
+
}))
|
|
25
|
+
.sort((a, b) =>
|
|
26
|
+
Number(b.key.startsWith(needle)) - Number(a.key.startsWith(needle))
|
|
27
|
+
|| b.uses - a.uses
|
|
28
|
+
|| a.value.localeCompare(b.value))
|
|
29
|
+
.slice(0, Math.min(Math.max(limit, 1), MAX))
|
|
30
|
+
.map(({ value, uses }) => ({ value, uses }));
|
|
31
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Aspect ratios for images, kept pure so they can be unit tested. A ratio is written "width:height"
|
|
3
|
+
("4:3", "16:9", "1:1"); "4/3" and a plain number such as "1.5" are accepted too.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_RATIOS = { category: '4:3', item: '4:3' };
|
|
6
|
+
|
|
7
|
+
const LIMIT = 10; // 10:1 either way; anything wilder is almost certainly a typo
|
|
8
|
+
|
|
9
|
+
/* Returns the ratio as CSS (`aspect-ratio`), e.g. "4 / 3", or null when it is not a usable ratio. */
|
|
10
|
+
export const parseRatio = value => {
|
|
11
|
+
const text = String(value ?? '').trim();
|
|
12
|
+
let width;
|
|
13
|
+
let height;
|
|
14
|
+
const pair = text.match(/^(\d+(?:\.\d+)?)\s*[:/xX]\s*(\d+(?:\.\d+)?)$/);
|
|
15
|
+
if(pair){
|
|
16
|
+
width = Number(pair[1]);
|
|
17
|
+
height = Number(pair[2]);
|
|
18
|
+
} else if(/^\d+(?:\.\d+)?$/.test(text)){
|
|
19
|
+
width = Number(text);
|
|
20
|
+
height = 1;
|
|
21
|
+
} else {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if(!(width > 0) || !(height > 0)) return null;
|
|
25
|
+
const ratio = width / height;
|
|
26
|
+
if(ratio > LIMIT || ratio < 1 / LIMIT) return null;
|
|
27
|
+
return `${width} / ${height}`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/* A usable ratio from the setting, or the default for `kind` when it is blank or invalid. */
|
|
31
|
+
export const resolveRatio = (value, kind) => parseRatio(value) ?? parseRatio(DEFAULT_RATIOS[kind]);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import { kempoInventoryItem } from '../db/schema.js';
|
|
4
|
+
import { getField, itemsInScope } from './fields.js';
|
|
5
|
+
import { rankSuggestions } from './rank.js';
|
|
6
|
+
|
|
7
|
+
export { rankSuggestions };
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
The values already used for a text field, matching what has been typed. An empty `q` gives the
|
|
11
|
+
most used values overall, which is what a field wants to offer the moment it is focused.
|
|
12
|
+
*/
|
|
13
|
+
export const getSuggestions = async (key, { q = '', limit = 8, category = '' } = {}) => {
|
|
14
|
+
const [lookupError, field] = await getField(key, category);
|
|
15
|
+
if(lookupError) return [lookupError, null];
|
|
16
|
+
if(field.type !== 'text') return [{ code: 400, msg: 'Only text fields can suggest values' }, null];
|
|
17
|
+
|
|
18
|
+
const term = String(q ?? '').trim();
|
|
19
|
+
/* LIKE treats % and _ as wildcards, so a typed one has to be escaped to mean itself. */
|
|
20
|
+
const pattern = `%${term.replace(/[\\%_]/g, '\\$&')}%`;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const rows = await db.execute(sql`
|
|
24
|
+
SELECT btrim(${kempoInventoryItem.data}->>${key}) AS value, count(*)::int AS uses
|
|
25
|
+
FROM ${kempoInventoryItem}
|
|
26
|
+
WHERE ${itemsInScope(field.category)}
|
|
27
|
+
AND btrim(${kempoInventoryItem.data}->>${key}) IS NOT NULL
|
|
28
|
+
AND btrim(${kempoInventoryItem.data}->>${key}) <> ''
|
|
29
|
+
${term ? sql`AND btrim(${kempoInventoryItem.data}->>${key}) ILIKE ${pattern}` : sql``}
|
|
30
|
+
GROUP BY 1
|
|
31
|
+
LIMIT 500`);
|
|
32
|
+
return [null, rankSuggestions([...rows], term, limit)];
|
|
33
|
+
} catch {
|
|
34
|
+
return [{ code: 500, msg: 'Failed to retrieve suggestions' }, null];
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import db from 'kempo/server/db/index.js';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import { kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Every tag in use on items and categories, with how many items and categories carry it, most used
|
|
7
|
+
first: [{ tag, count }]. `q` narrows it to tags containing that text (for suggestions as you type).
|
|
8
|
+
*/
|
|
9
|
+
export const getTags = async ({ q = '', limit = 100 } = {}) => {
|
|
10
|
+
const term = String(q ?? '').trim().toLowerCase();
|
|
11
|
+
const pattern = `%${term.replace(/[\\%_]/g, '\\$&')}%`;
|
|
12
|
+
try {
|
|
13
|
+
const rows = await db.execute(sql`
|
|
14
|
+
SELECT tag, count(*)::int AS count FROM (
|
|
15
|
+
SELECT jsonb_array_elements_text(${kempoInventoryItem.tags}) AS tag FROM ${kempoInventoryItem}
|
|
16
|
+
UNION ALL
|
|
17
|
+
SELECT jsonb_array_elements_text(${kempoInventoryCategory.tags}) AS tag FROM ${kempoInventoryCategory}
|
|
18
|
+
) t
|
|
19
|
+
${term ? sql`WHERE tag LIKE ${pattern}` : sql``}
|
|
20
|
+
GROUP BY tag ORDER BY count DESC, tag LIMIT ${limit}`);
|
|
21
|
+
return [null, [...rows].map(r => ({ tag: r.tag, count: r.count }))];
|
|
22
|
+
} catch {
|
|
23
|
+
return [{ code: 500, msg: 'Failed to retrieve tags' }, null];
|
|
24
|
+
}
|
|
25
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Tags: short labels on items and categories ("acrylic", "red", "water-based") that search finds.
|
|
3
|
+
Pure helpers with no database access, so they can be unit tested.
|
|
4
|
+
|
|
5
|
+
A tag is stored lower case with its spacing tidied, so "Water-Based" and " water-based " are one tag.
|
|
6
|
+
*/
|
|
7
|
+
export const MAX_TAG = 40;
|
|
8
|
+
export const MAX_TAGS = 30;
|
|
9
|
+
|
|
10
|
+
export const tidyTag = tag => String(tag ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
Accepts a list, or text separated by commas or new lines ("acrylic, red"), and returns
|
|
14
|
+
[error, tags] with the tags tidied, de-duplicated and in the order given.
|
|
15
|
+
*/
|
|
16
|
+
export const normalizeTags = input => {
|
|
17
|
+
if(input === undefined || input === null || input === '') return [null, []];
|
|
18
|
+
const list = Array.isArray(input) ? input : typeof input === 'string' ? input.split(/[,\n]/) : null;
|
|
19
|
+
if(!list) return [{ code: 400, msg: 'Tags must be a list' }, null];
|
|
20
|
+
const tags = [];
|
|
21
|
+
for(const raw of list){
|
|
22
|
+
if(typeof raw !== 'string') return [{ code: 400, msg: 'Each tag must be text' }, null];
|
|
23
|
+
const tag = tidyTag(raw);
|
|
24
|
+
if(!tag) continue;
|
|
25
|
+
if(tag.length > MAX_TAG) return [{ code: 400, msg: `A tag must be ${MAX_TAG} characters or fewer` }, null];
|
|
26
|
+
if(!tags.includes(tag)) tags.push(tag);
|
|
27
|
+
}
|
|
28
|
+
if(tags.length > MAX_TAGS) return [{ code: 400, msg: `At most ${MAX_TAGS} tags are allowed` }, null];
|
|
29
|
+
return [null, tags];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/* A `tag` query parameter: one tag, a repeated parameter, or a JSON list ('["a","b"]'), as a list. */
|
|
33
|
+
export const parseTagParam = value => {
|
|
34
|
+
if(Array.isArray(value)) return value.map(tidyTag).filter(Boolean);
|
|
35
|
+
if(typeof value !== 'string' || !value.trim()) return [];
|
|
36
|
+
if(value.trim().startsWith('[')){
|
|
37
|
+
try { return parseTagParam(JSON.parse(value)); } catch { /* not JSON: treat it as a tag */ }
|
|
38
|
+
}
|
|
39
|
+
return [tidyTag(value)].filter(Boolean);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/* The distinct tags across several lists with how many lists each is in, most used first. */
|
|
43
|
+
export const countTags = lists => {
|
|
44
|
+
const counts = new Map();
|
|
45
|
+
for(const list of lists) for(const tag of new Set(list ?? [])) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
46
|
+
return [...counts.entries()].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
|
47
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A minimal ZIP writer for exports: every entry is stored, not compressed. Photos and the other media
|
|
3
|
+
an export carries are already compressed, and the JSON is small, so compressing would cost time for
|
|
4
|
+
almost no saving, and "stored" keeps this to a few lines with no dependency. Any unzip tool opens the
|
|
5
|
+
result, and public/components/zip.js reads it (and zips made by other tools) back in the browser.
|
|
6
|
+
|
|
7
|
+
Entry names are written as UTF-8 (flag bit 11). Sizes are limited to 4 GB per file and in total, which
|
|
8
|
+
is far beyond an inventory.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const TABLE = (() => {
|
|
12
|
+
const table = new Uint32Array(256);
|
|
13
|
+
for(let n = 0; n < 256; n++){
|
|
14
|
+
let c = n;
|
|
15
|
+
for(let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
|
16
|
+
table[n] = c >>> 0;
|
|
17
|
+
}
|
|
18
|
+
return table;
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
export const crc32 = bytes => {
|
|
22
|
+
let crc = 0xFFFFFFFF;
|
|
23
|
+
for(let i = 0; i < bytes.length; i++) crc = TABLE[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
|
|
24
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/* DOS date and time, as ZIP stores them. */
|
|
28
|
+
const dosTime = date => ({
|
|
29
|
+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
|
|
30
|
+
date: ((Math.max(1980, date.getFullYear()) - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/*
|
|
34
|
+
Builds a ZIP file from entries: [{ name: 'dir/file.txt', data: Buffer | Uint8Array }]. Returns a Buffer.
|
|
35
|
+
*/
|
|
36
|
+
export const buildZip = (entries, now = new Date()) => {
|
|
37
|
+
const stamp = dosTime(now);
|
|
38
|
+
const locals = [];
|
|
39
|
+
const centrals = [];
|
|
40
|
+
let offset = 0;
|
|
41
|
+
|
|
42
|
+
for(const entry of entries){
|
|
43
|
+
const name = Buffer.from(entry.name, 'utf8');
|
|
44
|
+
const data = Buffer.from(entry.data);
|
|
45
|
+
const crc = crc32(data);
|
|
46
|
+
|
|
47
|
+
const local = Buffer.alloc(30);
|
|
48
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
49
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
50
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 names
|
|
51
|
+
local.writeUInt16LE(0, 8); // stored
|
|
52
|
+
local.writeUInt16LE(stamp.time, 10);
|
|
53
|
+
local.writeUInt16LE(stamp.date, 12);
|
|
54
|
+
local.writeUInt32LE(crc, 14);
|
|
55
|
+
local.writeUInt32LE(data.length, 18);
|
|
56
|
+
local.writeUInt32LE(data.length, 22);
|
|
57
|
+
local.writeUInt16LE(name.length, 26);
|
|
58
|
+
local.writeUInt16LE(0, 28);
|
|
59
|
+
|
|
60
|
+
const central = Buffer.alloc(46);
|
|
61
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
62
|
+
central.writeUInt16LE(20, 4); // version made by
|
|
63
|
+
central.writeUInt16LE(20, 6); // version needed
|
|
64
|
+
central.writeUInt16LE(0x0800, 8);
|
|
65
|
+
central.writeUInt16LE(0, 10);
|
|
66
|
+
central.writeUInt16LE(stamp.time, 12);
|
|
67
|
+
central.writeUInt16LE(stamp.date, 14);
|
|
68
|
+
central.writeUInt32LE(crc, 16);
|
|
69
|
+
central.writeUInt32LE(data.length, 20);
|
|
70
|
+
central.writeUInt32LE(data.length, 24);
|
|
71
|
+
central.writeUInt16LE(name.length, 28);
|
|
72
|
+
central.writeUInt32LE(offset, 42);
|
|
73
|
+
|
|
74
|
+
locals.push(local, name, data);
|
|
75
|
+
centrals.push(central, name);
|
|
76
|
+
offset += local.length + name.length + data.length;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const centralSize = centrals.reduce((sum, part) => sum + part.length, 0);
|
|
80
|
+
const end = Buffer.alloc(22);
|
|
81
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
82
|
+
end.writeUInt16LE(entries.length, 8);
|
|
83
|
+
end.writeUInt16LE(entries.length, 10);
|
|
84
|
+
end.writeUInt32LE(centralSize, 12);
|
|
85
|
+
end.writeUInt32LE(offset, 16);
|
|
86
|
+
|
|
87
|
+
return Buffer.concat([...locals, ...centrals, end]);
|
|
88
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Reads a ZIP file on the server, for importing an inventory export that has its media with it. Only the
|
|
3
|
+
import routes use it, and only after the caller has been checked against the top-tier permission. No dependencies: entries stored as they are, and entries compressed with deflate (what
|
|
4
|
+
Windows, macOS and most tools produce when they re-zip a folder) are inflated with the built-in
|
|
5
|
+
DecompressionStream.
|
|
6
|
+
|
|
7
|
+
const zip = await readZip(await file.arrayBuffer());
|
|
8
|
+
zip.names // ['inventory.json', 'media/1-photo.jpg', ...]
|
|
9
|
+
await zip.read('inventory.json') // Uint8Array, or null when there is no such entry
|
|
10
|
+
await zip.text('inventory.json') // the same, decoded as UTF-8
|
|
11
|
+
|
|
12
|
+
Entries are only ever read into memory, looked up by the exact name asked for; nothing is written to disk
|
|
13
|
+
and an entry's name is never used as a path. A deflated entry may inflate to at most `maxEntryBytes`
|
|
14
|
+
(200 MB by default). Not supported: encrypted zips, zip64 and multi-disk archives. An unsupported zip throws an Error with
|
|
15
|
+
a message fit to show a person.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/*
|
|
19
|
+
Inflates a deflated entry, giving up if it grows past `limit` bytes: a zip bomb is a tiny entry that
|
|
20
|
+
inflates to gigabytes, and nothing in the zip's own headers can be trusted to say how big it is.
|
|
21
|
+
*/
|
|
22
|
+
const inflateRaw = async (bytes, limit) => {
|
|
23
|
+
const reader = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate-raw')).getReader();
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let total = 0;
|
|
26
|
+
for(;;){
|
|
27
|
+
const { done, value } = await reader.read();
|
|
28
|
+
if(done) break;
|
|
29
|
+
total += value.length;
|
|
30
|
+
if(total > limit){
|
|
31
|
+
await reader.cancel();
|
|
32
|
+
throw new Error('A file in this zip is too large to read safely.');
|
|
33
|
+
}
|
|
34
|
+
chunks.push(value);
|
|
35
|
+
}
|
|
36
|
+
const out = new Uint8Array(total);
|
|
37
|
+
let at = 0;
|
|
38
|
+
for(const chunk of chunks){ out.set(chunk, at); at += chunk.length; }
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const MAX_ENTRY_BYTES = 200 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
export const MAX_ENTRIES = 20000;
|
|
45
|
+
|
|
46
|
+
export const readZip = async (buffer, { maxEntryBytes = MAX_ENTRY_BYTES, maxEntries = MAX_ENTRIES } = {}) => {
|
|
47
|
+
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
|
48
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
49
|
+
|
|
50
|
+
/* The end-of-central-directory record is somewhere in the last 64 KB. */
|
|
51
|
+
let end = -1;
|
|
52
|
+
for(let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65535); i--){
|
|
53
|
+
if(view.getUint32(i, true) === 0x06054b50){ end = i; break; }
|
|
54
|
+
}
|
|
55
|
+
if(end < 0) throw new Error('This is not a zip file.');
|
|
56
|
+
const count = view.getUint16(end + 10, true);
|
|
57
|
+
let pointer = view.getUint32(end + 16, true);
|
|
58
|
+
if(count === 0xFFFF || pointer === 0xFFFFFFFF) throw new Error('Zip64 archives are not supported.');
|
|
59
|
+
if(count > maxEntries) throw new Error(`This zip has more than ${maxEntries} files.`);
|
|
60
|
+
|
|
61
|
+
const decoder = new TextDecoder('utf-8');
|
|
62
|
+
const entries = new Map();
|
|
63
|
+
for(let n = 0; n < count; n++){
|
|
64
|
+
if(view.getUint32(pointer, true) !== 0x02014b50) throw new Error('The zip file is damaged.');
|
|
65
|
+
const flags = view.getUint16(pointer + 8, true);
|
|
66
|
+
const method = view.getUint16(pointer + 10, true);
|
|
67
|
+
const compressedSize = view.getUint32(pointer + 20, true);
|
|
68
|
+
const nameLength = view.getUint16(pointer + 28, true);
|
|
69
|
+
const extraLength = view.getUint16(pointer + 30, true);
|
|
70
|
+
const commentLength = view.getUint16(pointer + 32, true);
|
|
71
|
+
const localOffset = view.getUint32(pointer + 42, true);
|
|
72
|
+
const name = decoder.decode(bytes.subarray(pointer + 46, pointer + 46 + nameLength));
|
|
73
|
+
if(flags & 1) throw new Error('Encrypted zip files are not supported.');
|
|
74
|
+
if(!name.endsWith('/')) entries.set(name, { method, compressedSize, localOffset });
|
|
75
|
+
pointer += 46 + nameLength + extraLength + commentLength;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const read = async name => {
|
|
79
|
+
const entry = entries.get(name);
|
|
80
|
+
if(!entry) return null;
|
|
81
|
+
const at = entry.localOffset;
|
|
82
|
+
if(view.getUint32(at, true) !== 0x04034b50) throw new Error('The zip file is damaged.');
|
|
83
|
+
const start = at + 30 + view.getUint16(at + 26, true) + view.getUint16(at + 28, true);
|
|
84
|
+
const raw = bytes.subarray(start, start + entry.compressedSize);
|
|
85
|
+
if(entry.method === 0) return raw;
|
|
86
|
+
if(entry.method === 8) return inflateRaw(raw, maxEntryBytes);
|
|
87
|
+
throw new Error(`"${name}" uses a compression method this reader does not support.`);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
names: [...entries.keys()],
|
|
92
|
+
has: name => entries.has(name),
|
|
93
|
+
read,
|
|
94
|
+
text: async name => {
|
|
95
|
+
const data = await read(name);
|
|
96
|
+
return data ? decoder.decode(data) : null;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
};
|
package/update.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import db from 'kempo/server/db/index.js';
|
|
3
|
+
import { getSetting, setSetting } from 'kempo/server/sdk.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
kempo creates tables that are new in an update but never alters ones that already exist, so a
|
|
7
|
+
column added to an existing table has to be added here. Every statement is idempotent, so running
|
|
8
|
+
this on a database that already has the change is harmless.
|
|
9
|
+
*/
|
|
10
|
+
export default async () => {
|
|
11
|
+
// 0.15: items and categories can be owned by an extension ('' = managed by people, as before)
|
|
12
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "owner" text NOT NULL DEFAULT ''`);
|
|
13
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryCategory" ADD COLUMN IF NOT EXISTS "owner" text NOT NULL DEFAULT ''`);
|
|
14
|
+
await db.execute(sql`CREATE INDEX IF NOT EXISTS "kempoInventoryItemOwnerIdx" ON "kempoInventoryItem" ("owner")`);
|
|
15
|
+
|
|
16
|
+
// 0.14: text is stored without leading or trailing spaces. Trim what is already stored: custom field values that are text, plus names and descriptions.
|
|
17
|
+
const edge = "'^[[:space:]' || chr(160) || ']+|[[:space:]' || chr(160) || ']+$'";
|
|
18
|
+
await db.execute(sql.raw(`
|
|
19
|
+
UPDATE "kempoInventoryItem" SET "data" = (
|
|
20
|
+
SELECT COALESCE(jsonb_object_agg(e.key, CASE WHEN jsonb_typeof(e.value) = 'string' THEN to_jsonb(regexp_replace(e.value #>> '{}', ${edge}, '', 'g')) ELSE e.value END), '{}'::jsonb)
|
|
21
|
+
FROM jsonb_each("data") e)
|
|
22
|
+
WHERE EXISTS (SELECT 1 FROM jsonb_each("data") e WHERE jsonb_typeof(e.value) = 'string' AND (e.value #>> '{}') ~ (${edge}))`));
|
|
23
|
+
for(const column of ['name', 'description']){
|
|
24
|
+
await db.execute(sql.raw(`UPDATE "kempoInventoryItem" SET "${column}" = regexp_replace("${column}", ${edge}, '', 'g') WHERE "${column}" ~ (${edge})`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 0.13: tags are lower case. Lower-case every tag already stored, tidy its spacing, drop duplicates the change creates, keep the order.
|
|
28
|
+
for(const table of ['kempoInventoryItem', 'kempoInventoryCategory']){
|
|
29
|
+
await db.execute(sql.raw(`
|
|
30
|
+
UPDATE "${table}" SET "tags" = COALESCE((
|
|
31
|
+
SELECT jsonb_agg(t ORDER BY first) FROM (
|
|
32
|
+
SELECT lower(regexp_replace(btrim(x), '[[:space:]]+', ' ', 'g')) AS t, min(ord) AS first
|
|
33
|
+
FROM jsonb_array_elements_text("tags") WITH ORDINALITY AS a(x, ord)
|
|
34
|
+
WHERE btrim(x) <> '' GROUP BY 1
|
|
35
|
+
) s
|
|
36
|
+
), '[]'::jsonb)
|
|
37
|
+
WHERE "tags"::text <> lower("tags"::text)`));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 0.12: the import size limit. Added only when missing.
|
|
41
|
+
const [, importMax] = await getSetting('kempo-inventory', 'import_max_mb', null);
|
|
42
|
+
if(importMax === null) await setSetting('kempo-inventory', 'import_max_mb', 250, 'number', false, 'The largest import file (a .json or .zip made by Export) the admin Import accepts, in MB.');
|
|
43
|
+
|
|
44
|
+
// 0.11: photo fields can force a shape and a maximum size
|
|
45
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryField" ADD COLUMN IF NOT EXISTS "imageRatio" text NOT NULL DEFAULT ''`);
|
|
46
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryField" ADD COLUMN IF NOT EXISTS "imageMax" integer NOT NULL DEFAULT 0`);
|
|
47
|
+
|
|
48
|
+
// 0.10: tags on items and categories
|
|
49
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "tags" jsonb NOT NULL DEFAULT '[]'::jsonb`);
|
|
50
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryCategory" ADD COLUMN IF NOT EXISTS "tags" jsonb NOT NULL DEFAULT '[]'::jsonb`);
|
|
51
|
+
|
|
52
|
+
// 0.9: optional public (signed-out) browsing. Added only when missing, and off.
|
|
53
|
+
const [, publicRead] = await getSetting('kempo-inventory', 'public_read', null);
|
|
54
|
+
if(publicRead === null) await setSetting('kempo-inventory', 'public_read', false, 'boolean', false, 'Let anyone, signed in or not, browse the inventory (items, categories, photos). They never see stock counts. Off by default: reading otherwise needs the items:read permission.');
|
|
55
|
+
|
|
56
|
+
// 0.8: photos are public by default, so read-only users can see them. Added only when missing.
|
|
57
|
+
const [, publicSetting] = await getSetting('kempo-inventory', 'public_photos', null);
|
|
58
|
+
if(publicSetting === null) await setSetting('kempo-inventory', 'public_photos', true, 'boolean', false, 'Photos uploaded to kempo-files are viewable by anyone with the link, so every person who can read the inventory sees them (kempo-files otherwise needs each of them to have its files:download permission). Turn off to keep photos behind that permission.');
|
|
59
|
+
const [, makePublic] = await getSetting('kempo-inventory', 'public_photos', true);
|
|
60
|
+
if(makePublic !== false && makePublic !== 'false'){
|
|
61
|
+
/* Photos already uploaded: every kempo-files file an item or category uses, and its thumbnails. */
|
|
62
|
+
const [{ files, thumbs }] = [...await db.execute(sql`SELECT to_regclass('"kempoFile"') AS files, to_regclass('"kempoThumbnail"') AS thumbs`)];
|
|
63
|
+
if(files){
|
|
64
|
+
await db.execute(sql`
|
|
65
|
+
UPDATE "kempoFile" SET "public" = true
|
|
66
|
+
WHERE "public" = false AND "id" IN (
|
|
67
|
+
SELECT substr(v, 7) FROM "kempoInventoryItem" i,
|
|
68
|
+
LATERAL jsonb_each(i."data") e(k, val),
|
|
69
|
+
LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(val) = 'array' THEN val ELSE '[]'::jsonb END) v
|
|
70
|
+
WHERE v LIKE 'files:%'
|
|
71
|
+
UNION SELECT substr("image", 7) FROM "kempoInventoryCategory" WHERE "image" LIKE 'files:%')`);
|
|
72
|
+
if(thumbs){
|
|
73
|
+
await db.execute(sql`
|
|
74
|
+
UPDATE "kempoFile" SET "public" = true
|
|
75
|
+
WHERE "public" = false AND "id" IN (
|
|
76
|
+
SELECT t."fileId" FROM "kempoThumbnail" t JOIN "kempoFile" f ON f."id" = t."sourceFileId"
|
|
77
|
+
WHERE f."public" = true AND t."fileId" IS NOT NULL)`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 0.7: image aspect ratio settings. Added only when missing, so a value someone chose is never overwritten.
|
|
83
|
+
for(const [name, description] of [
|
|
84
|
+
['category_image_ratio', 'The aspect ratio category images are shown at, as width:height (4:3, 1:1, 16:9). Images are cropped to fit, never stretched.'],
|
|
85
|
+
['item_image_ratio', 'The aspect ratio item photos are shown at, as width:height (4:3, 1:1, 16:9). Images are cropped to fit, never stretched.'],
|
|
86
|
+
]){
|
|
87
|
+
const [, existing] = await getSetting('kempo-inventory', name, null);
|
|
88
|
+
if(existing === null) await setSetting('kempo-inventory', name, '4:3', 'string', false, description);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 0.2: text fields can suggest values already used on other items
|
|
92
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryField" ADD COLUMN IF NOT EXISTS "suggest" boolean NOT NULL DEFAULT false`);
|
|
93
|
+
|
|
94
|
+
// 0.3: categories can have an image
|
|
95
|
+
await db.execute(sql`
|
|
96
|
+
CREATE TABLE IF NOT EXISTS "kempoInventoryCategory" (
|
|
97
|
+
"id" text PRIMARY KEY,
|
|
98
|
+
"key" text NOT NULL,
|
|
99
|
+
"name" text NOT NULL,
|
|
100
|
+
"image" text,
|
|
101
|
+
"created" timestamp NOT NULL,
|
|
102
|
+
"updated" timestamp NOT NULL
|
|
103
|
+
)`);
|
|
104
|
+
await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoInventoryCategoryKeyIdx" ON "kempoInventoryCategory" ("key")`);
|
|
105
|
+
|
|
106
|
+
// 0.4: category is a built-in property of every item, not a custom field
|
|
107
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "category" text NOT NULL DEFAULT ''`);
|
|
108
|
+
const oldField = [...await db.execute(sql`SELECT 1 FROM "kempoInventoryField" WHERE "key" = 'category' LIMIT 1`)];
|
|
109
|
+
if(oldField.length){
|
|
110
|
+
/* Move each item's value across (tidied the way new ones are), then drop the old field. */
|
|
111
|
+
await db.execute(sql`
|
|
112
|
+
UPDATE "kempoInventoryItem"
|
|
113
|
+
SET "category" = regexp_replace(btrim(coalesce("data"->>'category', '')), '[[:space:]]+', ' ', 'g'),
|
|
114
|
+
"data" = "data" - 'category'
|
|
115
|
+
WHERE "data"->>'category' IS NOT NULL`);
|
|
116
|
+
await db.execute(sql`DELETE FROM "kempoInventoryField" WHERE "key" = 'category'`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 0.5: categories can have a description
|
|
120
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryCategory" ADD COLUMN IF NOT EXISTS "description" text NOT NULL DEFAULT ''`);
|
|
121
|
+
|
|
122
|
+
// 0.6: fields can belong to one category ('' = every item), so a key is unique per category
|
|
123
|
+
await db.execute(sql`ALTER TABLE "kempoInventoryField" ADD COLUMN IF NOT EXISTS "category" text NOT NULL DEFAULT ''`);
|
|
124
|
+
await db.execute(sql`DROP INDEX IF EXISTS "kempoInventoryFieldKeyIdx"`);
|
|
125
|
+
await db.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS "kempoInventoryFieldKeyIdx" ON "kempoInventoryField" ("category", "key")`);
|
|
126
|
+
};
|