kempo-inventory 0.2.1 → 0.3.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 +32 -13
- package/admin/edit/index.page.html +6 -0
- package/admin/nav.global.html +1 -1
- package/kempo-config.json +1 -7
- package/package.json +8 -3
- package/public/components/ItemFields.js +3 -3
- package/sdk.js +1 -0
- package/server/utils/items.js +96 -0
- package/server/utils/media.js +25 -35
- package/update.js +4 -1
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ Reading the inventory needs the `items:read` permission. Turn on the `public_rea
|
|
|
130
130
|
|
|
131
131
|
## Who can see photos
|
|
132
132
|
|
|
133
|
-
kempo-files only serves a file to people with its `files:download` permission, which someone who can merely *read* the inventory does not have, so their photos would be broken images. The `public_photos` setting (on by default) uploads photos to kempo-
|
|
133
|
+
kempo-files only serves a file to people with its `files:download` permission, which someone who can merely *read* the inventory does not have, so their photos would be broken images. The `public_photos` setting (on by default) uploads photos as **public**, whether they go to kempo-media or kempo-files: viewable by anyone who has the link (ids are unguessable), which includes every inventory reader. Turn it off to keep photos behind `files:download`; then give your readers that permission. Changing the setting only affects new uploads. `update.js` also marks the photos items and categories already use (and their thumbnails) public when the setting is on.
|
|
134
134
|
|
|
135
135
|
## Photos and files (optional)
|
|
136
136
|
|
|
@@ -138,21 +138,15 @@ Three optional extensions can take part. Inventory never imports any of them up
|
|
|
138
138
|
|
|
139
139
|
| Extension | Role |
|
|
140
140
|
|---|---|
|
|
141
|
-
| [kempo-media](https://github.com/dustinpoissant/kempo-media) | A media library
|
|
142
|
-
| [kempo-files](https://github.com/dustinpoissant/kempo-files) | A
|
|
143
|
-
| [kempo-thumbs](https://github.com/dustinpoissant/kempo-thumbs) | Generates thumbnails for kempo-files, including frames from video and audio
|
|
141
|
+
| [kempo-media](https://github.com/dustinpoissant/kempo-media) | A media library: a kempo-files file plus kind, dimensions and alt text, with thumbnails from kempo-thumbs. It requires the other two, so installing it brings all three |
|
|
142
|
+
| [kempo-files](https://github.com/dustinpoissant/kempo-files) | A file library: files live outside `public/` and every download is permission-checked, unless the file is marked public |
|
|
143
|
+
| [kempo-thumbs](https://github.com/dustinpoissant/kempo-thumbs) | Generates thumbnails for kempo-files, including frames from video and audio |
|
|
144
144
|
|
|
145
145
|
When an item has more than one photo, the first is its **primary** photo (the one shown on cards and lists). In the item form each other photo has a **Make primary** button that moves it to the front.
|
|
146
146
|
|
|
147
|
-
Add a field of type "Photos / files" and every item gets an upload area. New uploads go to one
|
|
147
|
+
Add a field of type "Photos / files" and every item gets an upload area. New uploads go to kempo-media when it is installed (it is kempo-files with thumbnails, so one upload gives you a stored file and its thumbnail), and to kempo-files directly on a site that has only that. There is no setting to choose; the old `media_provider` setting was retired, and `update.js` removes it.
|
|
148
148
|
|
|
149
|
-
|
|
150
|
-
|---|---|
|
|
151
|
-
| `auto` (default) | kempo-files when kempo-thumbs is also installed, otherwise kempo-media, otherwise kempo-files |
|
|
152
|
-
| `kempo-media` | kempo-media (if installed) |
|
|
153
|
-
| `kempo-files` | kempo-files (if installed) |
|
|
154
|
-
|
|
155
|
-
A stored value records where its file lives, so files from both libraries keep working on the same item whichever one takes new uploads. The API returns a `media` map (stored id → `{ kind, name, alt, path, thumbnail }`) next to `linked`, so a client can show them. A kempo-files photo has no `thumbnail` until kempo-thumbs has generated one, so clients should fall back to `path`.
|
|
149
|
+
A stored value records where its file lives (a bare id is a kempo-media asset, `files:<id>` a kempo-files file), so values written before this change, or by a site with only kempo-files, keep working on the same item as newer ones. The API returns a `media` map (stored id → `{ kind, name, alt, path, thumbnail }`) next to `linked`, so a client can show them. A kempo-files photo has no `thumbnail` until kempo-thumbs has generated one, so clients should fall back to `path`.
|
|
156
150
|
|
|
157
151
|
- If a library is disabled later, items keep their stored ids and everything else keeps working. Files from that library show as missing, and only *adding* a file from it is refused; an item can still be edited with its existing list unchanged.
|
|
158
152
|
- Deleting a file from its library leaves the id on the item, shown as a missing file.
|
|
@@ -314,7 +308,8 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
|
|
|
314
308
|
| `createItem({ sku, name, description, quantity, fields, userId }, { owner })` | Create. `owner` makes it your extension's |
|
|
315
309
|
| `updateItem(id, { sku, name, description, fields, userId }, { owner })` | Update; `fields` is merged, and an empty value clears a field. SKU, name and category of an owned item need its `owner` |
|
|
316
310
|
| `deleteItem(id, { userId, owner })` | Delete an item and its history (owned items need their `owner`) |
|
|
317
|
-
| `adjustStock(id, { delta, reason, note, userId })` | The
|
|
311
|
+
| `adjustStock(id, { delta, reason, note, userId })` | The way quantity changes, for anyone. Atomic; refuses to go below zero |
|
|
312
|
+
| `adjustStockMany(changes, { reason, note, ref, userId, owner })` | Several stock changes together: every one happens or none does. `changes` is `[{ id, delta }]`. Use it when one event touches several items, such as the materials of a recipe |
|
|
318
313
|
| `unregisterItems(owner, { release })` | Delete every item you own, or with `release` hand them back to people |
|
|
319
314
|
| `getMovements({ itemId, limit, offset })` | Stock history |
|
|
320
315
|
| `getFields({ category, owner })`, `getField(key, category)` | Read field definitions. `getFields()` is every field (each with its `category` key and `categoryName`); with `category` it is what an item in that category shows |
|
|
@@ -328,6 +323,26 @@ Every function resolves to `[error, result]`, where `error` is `{ code, msg }` o
|
|
|
328
323
|
|
|
329
324
|
An item looks like `{ id, sku, name, description, category, tags, quantity, fields: { ...custom values }, owner, created, updated }`.
|
|
330
325
|
|
|
326
|
+
### Several items at once
|
|
327
|
+
|
|
328
|
+
`adjustStock` changes one item in its own transaction, so a sale that uses resin, paint and filament could take the resin and then find the paint short. `adjustStockMany` applies the whole set together:
|
|
329
|
+
|
|
330
|
+
```javascript
|
|
331
|
+
import { adjustStockMany } from 'kempo-inventory/sdk';
|
|
332
|
+
|
|
333
|
+
const [error, { items }] = await adjustStockMany(
|
|
334
|
+
[{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
|
|
335
|
+
{ reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
|
|
336
|
+
);
|
|
337
|
+
// error.code 409 and error.msg "Insufficient stock of Paint (ml)": nothing changed
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Changes to the same item are added together and one that nets to nothing is left out. Every item goes through the same `before_adjust` guard as `adjustStock`, and each fires `stock:adjusted` once everything is committed. `ref` is written on every stock movement (as `[order-1042]` in its note) so the change can be found, and reversed, later.
|
|
341
|
+
|
|
342
|
+
### Adding to the item page
|
|
343
|
+
|
|
344
|
+
An extension can add to the admin item page by supplying a fragment named `inventory-item-actions`, in `admin/inventory-item-actions.fragment.html` in its own package. It appears under the item form, and reads the item from `?id=` in the address. (kempo-products-inventory uses it for "Make product from this item".)
|
|
345
|
+
|
|
331
346
|
### Hooks
|
|
332
347
|
|
|
333
348
|
Declare these in your `kempo-config.json` `hooks`. Every payload also carries `actor`: the extension that made the change through the SDK, or `''` for a person using the admin.
|
|
@@ -427,3 +442,7 @@ All routes require a session holding the permission above.
|
|
|
427
442
|
- Uninstalling drops the item, field and movement tables, including your data.
|
|
428
443
|
- Custom values are stored in a single `jsonb` column, so adding a field never alters the schema.
|
|
429
444
|
- `item` links store the linked item's id. Deleting an item leaves any links to it in place, shown as "(deleted item)".
|
|
445
|
+
|
|
446
|
+
## Development
|
|
447
|
+
|
|
448
|
+
The documentation site is written in `docs-src/` and built into `docs/` (served by GitHub Pages from the `docs` folder). Edit `docs-src/`, never `docs/`, then run `npm run docs:build`; `npm run docs:dev` serves it on port 4050. Screenshots come in pairs: write `<img src="./media/name.png" ...>` in a page, and put `name-light.png` and `name-dark.png` in `docs-src/media`. The build turns the tag into both images and the site shows the one matching the visitor's theme.
|
|
@@ -12,6 +12,12 @@ locked: true
|
|
|
12
12
|
|
|
13
13
|
<div id="editItem" style="max-width: 44rem;"></div>
|
|
14
14
|
|
|
15
|
+
<!--
|
|
16
|
+
An extension can add to this page by supplying a fragment with this name in its own admin/
|
|
17
|
+
directory (inventory-item-actions.fragment.html). It reads the item from ?id= in the address.
|
|
18
|
+
-->
|
|
19
|
+
<fragment name="inventory-item-actions"></fragment>
|
|
20
|
+
|
|
15
21
|
<script type="module">
|
|
16
22
|
import { getItem, getFields } from '/inventory/sdk.js';
|
|
17
23
|
import { getAllCurrentUserPermissions } from '/kempo/sdk.js';
|
package/admin/nav.global.html
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<content id="kempo-inventory-nav" name="Inventory" owner="kempo-inventory" enabled="true" locked="true" location="admin-nav-extensions" priority="20">
|
|
2
|
-
<k-aside-item icon="archive" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
|
|
2
|
+
<k-aside-item icon="archive" group="Commerce" href="/admin/extension/kempo-inventory/" no-expand>Inventory</k-aside-item>
|
|
3
3
|
</content>
|
package/kempo-config.json
CHANGED
|
@@ -63,12 +63,6 @@
|
|
|
63
63
|
}
|
|
64
64
|
],
|
|
65
65
|
"settings": [
|
|
66
|
-
{
|
|
67
|
-
"name": "media_provider",
|
|
68
|
-
"value": "auto",
|
|
69
|
-
"type": "string",
|
|
70
|
-
"description": "Where new photos and files are uploaded: auto, kempo-media or kempo-files. Auto uses kempo-files when kempo-thumbs is installed to thumbnail it, otherwise kempo-media."
|
|
71
|
-
},
|
|
72
66
|
{
|
|
73
67
|
"name": "import_max_mb",
|
|
74
68
|
"value": 250,
|
|
@@ -85,7 +79,7 @@
|
|
|
85
79
|
"name": "public_photos",
|
|
86
80
|
"value": true,
|
|
87
81
|
"type": "boolean",
|
|
88
|
-
"description": "
|
|
82
|
+
"description": "New photos are viewable by anyone with the link, so every person who can read the inventory sees them (a private file otherwise needs each of them to have the files:download permission). Turn off to keep photos behind that permission."
|
|
89
83
|
},
|
|
90
84
|
{
|
|
91
85
|
"name": "category_image_ratio",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-inventory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A basic inventory management system for kempo cms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Dustin Poissant",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"main": "sdk.js",
|
|
17
17
|
"scripts": {
|
|
18
18
|
"test": "node --test",
|
|
19
|
-
"link:local": "npm install ../kempo --no-save"
|
|
19
|
+
"link:local": "npm install ../kempo ../kempo-server ../kempo-ui --no-save --no-legacy-peer-deps --force",
|
|
20
|
+
"docs:build": "node scripts/build-docs.js",
|
|
21
|
+
"docs:dev": "npx kempo-server --root docs --port 4050"
|
|
20
22
|
},
|
|
21
23
|
"kempo": {
|
|
22
24
|
"public-scope": "inventory"
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
"peerDependencies": {
|
|
42
44
|
"kempo": ">=4.3.0",
|
|
43
45
|
"kempo-ui": ">=0.5.0",
|
|
44
|
-
"kempo-media": ">=0.
|
|
46
|
+
"kempo-media": ">=0.3.0",
|
|
45
47
|
"kempo-files": ">=0.1.3",
|
|
46
48
|
"kempo-thumbs": ">=0.1.3"
|
|
47
49
|
},
|
|
@@ -55,5 +57,8 @@
|
|
|
55
57
|
"kempo-thumbs": {
|
|
56
58
|
"optional": true
|
|
57
59
|
}
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"highlight.js": "^11.11.1"
|
|
58
63
|
}
|
|
59
64
|
}
|
|
@@ -42,7 +42,7 @@ const MEDIA_ACCEPT = 'image/*,video/*,audio/*,.glb,.gltf,.obj,.fbx,.zip';
|
|
|
42
42
|
|
|
43
43
|
/*
|
|
44
44
|
Which library new uploads go to ('media', 'files' or null), decided by the server from what is
|
|
45
|
-
installed
|
|
45
|
+
installed: kempo-media when it is there, otherwise kempo-files. Asked once per page.
|
|
46
46
|
*/
|
|
47
47
|
let infoPromise = null;
|
|
48
48
|
const fieldInfo = () => infoPromise ??= getFields().then(([error, data]) => {
|
|
@@ -50,7 +50,7 @@ const fieldInfo = () => infoPromise ??= getFields().then(([error, data]) => {
|
|
|
50
50
|
return error ? null : data;
|
|
51
51
|
});
|
|
52
52
|
const uploadProvider = () => fieldInfo().then(data => data?.capabilities.upload ?? null);
|
|
53
|
-
/* Whether
|
|
53
|
+
/* Whether new uploads are made public (the public_photos setting). */
|
|
54
54
|
const uploadsArePublic = () => fieldInfo().then(data => data?.capabilities.publicFiles !== false);
|
|
55
55
|
|
|
56
56
|
/*
|
|
@@ -66,7 +66,7 @@ export const imageRatios = () => fieldInfo().then(data => data?.imageRatios ?? {
|
|
|
66
66
|
*/
|
|
67
67
|
const uploadToMedia = async file => {
|
|
68
68
|
const { uploadMedia } = await import('/kempo-media/sdk.js');
|
|
69
|
-
const [error, data] = await uploadMedia(file);
|
|
69
|
+
const [error, data] = await uploadMedia(file, { public: await uploadsArePublic() });
|
|
70
70
|
if(error) return [error, null];
|
|
71
71
|
const asset = data.asset;
|
|
72
72
|
return [null, { id: asset.id, asset: {
|
package/sdk.js
CHANGED
package/server/utils/items.js
CHANGED
|
@@ -377,6 +377,102 @@ export const adjustStock = async (id, { delta, reason = 'adjustment', note = '',
|
|
|
377
377
|
}
|
|
378
378
|
};
|
|
379
379
|
|
|
380
|
+
/*
|
|
381
|
+
Thrown inside a transaction to roll everything back with a message the caller can show.
|
|
382
|
+
*/
|
|
383
|
+
class Refusal {
|
|
384
|
+
constructor(error){
|
|
385
|
+
this.error = error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/*
|
|
390
|
+
Applies several stock changes together: every one happens or none does. This is what makes
|
|
391
|
+
taking the materials for a whole recipe safe, where one adjustStock per item could take the resin
|
|
392
|
+
and then fail on the paint. `changes` is [{ id, delta, reason?, note? }]; changes to the same item
|
|
393
|
+
are added together, and one that nets to nothing is left out. Each item runs through the same
|
|
394
|
+
before_adjust guard as adjustStock, and fires the same stock:adjusted notification once everything
|
|
395
|
+
is committed.
|
|
396
|
+
|
|
397
|
+
const [error, result] = await adjustStockMany(
|
|
398
|
+
[{ id: resin.id, delta: -175 }, { id: paint.id, delta: -20 }],
|
|
399
|
+
{ reason: 'sale', ref: 'order-1042', owner: 'my-extension' },
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
`ref` is recorded on every movement so the change can be found and reversed later. A refusal for
|
|
403
|
+
lack of stock is a 409 that names the item, and nothing has changed. Resolves to
|
|
404
|
+
[null, { items }] with the items as they are now.
|
|
405
|
+
*/
|
|
406
|
+
export const adjustStockMany = async (changes, { reason = 'adjustment', note = '', ref = '', userId = '', owner = '' } = {}) => {
|
|
407
|
+
if(!Array.isArray(changes) || !changes.length) return [{ code: 400, msg: 'changes must be a non-empty list' }, null];
|
|
408
|
+
|
|
409
|
+
const totals = new Map();
|
|
410
|
+
for(const change of changes){
|
|
411
|
+
const id = String(change?.id ?? '');
|
|
412
|
+
const delta = toInt(change?.delta, NaN);
|
|
413
|
+
if(!id) return [{ code: 400, msg: 'Every change needs an item id' }, null];
|
|
414
|
+
if(Number.isNaN(delta)) return [{ code: 400, msg: 'Every delta must be a whole number' }, null];
|
|
415
|
+
const entry = totals.get(id) ?? { id, delta: 0, reason: change.reason, note: change.note };
|
|
416
|
+
entry.delta += delta;
|
|
417
|
+
totals.set(id, entry);
|
|
418
|
+
}
|
|
419
|
+
/* In a fixed order, so two overlapping calls lock rows the same way round and cannot deadlock. */
|
|
420
|
+
const wanted = [...totals.values()].filter(entry => entry.delta !== 0).sort((a, b) => a.id.localeCompare(b.id));
|
|
421
|
+
if(!wanted.length) return [null, { items: [] }];
|
|
422
|
+
|
|
423
|
+
let rows;
|
|
424
|
+
try {
|
|
425
|
+
rows = await db.select().from(kempoInventoryItem).where(inArray(kempoInventoryItem.id, wanted.map(entry => entry.id)));
|
|
426
|
+
} catch {
|
|
427
|
+
return [{ code: 500, msg: 'Failed to adjust stock' }, null];
|
|
428
|
+
}
|
|
429
|
+
const existing = new Map(rows.map(row => [row.id, shape(row)]));
|
|
430
|
+
const missing = wanted.find(entry => !existing.has(entry.id));
|
|
431
|
+
if(missing) return [{ code: 404, msg: 'An item in this change no longer exists' }, null];
|
|
432
|
+
|
|
433
|
+
const drafts = new Map();
|
|
434
|
+
for(const entry of wanted){
|
|
435
|
+
const draft = { delta: entry.delta, reason: entry.reason ?? reason, note: entry.note ?? note };
|
|
436
|
+
const refused = await guard(EVENTS.stockBeforeAdjust, { item: existing.get(entry.id), draft, userId, actor: owner });
|
|
437
|
+
if(refused) return [refused, null];
|
|
438
|
+
const delta = toInt(draft.delta, NaN);
|
|
439
|
+
if(Number.isNaN(delta) || delta === 0) return [{ code: 400, msg: 'delta must be a non-zero whole number' }, null];
|
|
440
|
+
drafts.set(entry.id, { delta, reason: String(draft.reason || 'adjustment'), note: String(draft.note || '') });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const applied = await db.transaction(async tx => {
|
|
445
|
+
const results = [];
|
|
446
|
+
for(const entry of wanted){
|
|
447
|
+
const { delta, reason: why, note: text } = drafts.get(entry.id);
|
|
448
|
+
const [row] = await tx.update(kempoInventoryItem)
|
|
449
|
+
.set({ quantity: sql`${kempoInventoryItem.quantity} + ${delta}`, updated: new Date() })
|
|
450
|
+
.where(and(eq(kempoInventoryItem.id, entry.id), sql`${kempoInventoryItem.quantity} + ${delta} >= 0`))
|
|
451
|
+
.returning();
|
|
452
|
+
if(!row) throw new Refusal({ code: 409, msg: `Insufficient stock of ${existing.get(entry.id).name}` });
|
|
453
|
+
const [movement] = await tx.insert(kempoInventoryMovement).values({
|
|
454
|
+
id: newId(), itemId: entry.id, delta, quantityAfter: row.quantity, reason: why,
|
|
455
|
+
note: ref ? `${text ? `${text} ` : ''}[${ref}]` : text, userId, created: new Date(),
|
|
456
|
+
}).returning();
|
|
457
|
+
results.push({ row, movement, delta });
|
|
458
|
+
}
|
|
459
|
+
return results;
|
|
460
|
+
});
|
|
461
|
+
const items = [];
|
|
462
|
+
for(const { row, movement, delta } of applied){
|
|
463
|
+
const item = shape(row);
|
|
464
|
+
items.push(item);
|
|
465
|
+
await notify(EVENTS.stockAdjusted, {
|
|
466
|
+
item, previousQuantity: item.quantity - delta, delta, reason: movement.reason, note: movement.note, movement, userId, actor: owner,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return [null, { items }];
|
|
470
|
+
} catch(error) {
|
|
471
|
+
if(error instanceof Refusal) return [error.error, null];
|
|
472
|
+
return [{ code: 500, msg: 'Failed to adjust stock' }, null];
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
380
476
|
export const getMovements = async ({ itemId, limit = 50, offset = 0 } = {}) => {
|
|
381
477
|
try {
|
|
382
478
|
const where = itemId ? eq(kempoInventoryMovement.itemId, itemId) : undefined;
|
package/server/utils/media.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { readFile } from 'fs/promises';
|
|
2
|
-
import { join } from 'path';
|
|
3
2
|
import { getExtension, getSetting, triggerHook } from 'kempo/server/sdk.js';
|
|
4
|
-
import {
|
|
3
|
+
import { safeFileName } from './mediaSafety.js';
|
|
5
4
|
import { DEFAULT_RATIOS, resolveRatio } from './ratio.js';
|
|
6
5
|
import { parseMediaId, filesId } from './fieldTypes.js';
|
|
7
6
|
|
|
@@ -9,16 +8,18 @@ import { parseMediaId, filesId } from './fieldTypes.js';
|
|
|
9
8
|
Where item photos and files live. Three optional extensions can take part, and inventory works
|
|
10
9
|
the same with none of them installed:
|
|
11
10
|
|
|
12
|
-
kempo-media a media library
|
|
13
|
-
|
|
11
|
+
kempo-media a media library: a kempo-files file plus kind, dimensions and alt text, with
|
|
12
|
+
thumbnails from kempo-thumbs. It requires the other two.
|
|
13
|
+
kempo-files a file library; a non-public file needs files:download to view
|
|
14
14
|
kempo-thumbs generates thumbnails for kempo-files (images, and frames from video and audio)
|
|
15
15
|
|
|
16
16
|
Nothing here imports them up front. Each is loaded on demand and only counts as available when it
|
|
17
|
-
is both installed as a package and enabled in kempo.
|
|
18
|
-
kempo-files, it is only ever used for files stored there.
|
|
17
|
+
is both installed as a package and enabled in kempo.
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
New uploads go to kempo-media when it is there, because it already is kempo-files with thumbnails;
|
|
20
|
+
only a site with kempo-files alone uploads to kempo-files directly. Stored values keep saying where
|
|
21
|
+
they live, so older ones keep resolving: a bare id is a kempo-media asset, "files:<id>" is a
|
|
22
|
+
kempo-files file.
|
|
22
23
|
*/
|
|
23
24
|
const loaders = {
|
|
24
25
|
'kempo-media': () => import('kempo-media/sdk'),
|
|
@@ -51,22 +52,11 @@ export const mediaAvailable = async () => {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
/*
|
|
54
|
-
Which
|
|
55
|
-
|
|
56
|
-
The `media_provider` setting can force one ('kempo-media' or 'kempo-files'). Left on 'auto',
|
|
57
|
-
kempo-files wins when kempo-thumbs is there to thumbnail it, because that is a better result than
|
|
58
|
-
kempo-media's; otherwise kempo-media wins, since a bare kempo-files has no thumbnails at all.
|
|
55
|
+
Which library new uploads go to: 'media', 'files' or null when neither is installed.
|
|
59
56
|
*/
|
|
60
|
-
export const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if(choice === 'kempo-media' && found.media) return 'media';
|
|
64
|
-
if(choice === 'kempo-files' && found.files) return 'files';
|
|
65
|
-
if(found.files && found.thumbs) return 'files';
|
|
66
|
-
if(found.media) return 'media';
|
|
67
|
-
if(found.files) return 'files';
|
|
68
|
-
return null;
|
|
69
|
-
};
|
|
57
|
+
export const chooseUploadProvider = found => found.media ? 'media' : found.files ? 'files' : null;
|
|
58
|
+
|
|
59
|
+
export const uploadProvider = async () => chooseUploadProvider(await providers());
|
|
70
60
|
|
|
71
61
|
/*
|
|
72
62
|
The aspect ratios images are shown at, as CSS ("4 / 3"): { category, item }. They come from the
|
|
@@ -78,7 +68,7 @@ export const getImageRatios = async () => {
|
|
|
78
68
|
return { category: resolveRatio(category, 'category'), item: resolveRatio(item, 'item') };
|
|
79
69
|
};
|
|
80
70
|
|
|
81
|
-
/* Whether photos
|
|
71
|
+
/* Whether new photos are made public, so people who can only read the inventory can see them. */
|
|
82
72
|
export const publicPhotos = async () => {
|
|
83
73
|
const [, value] = await getSetting('kempo-inventory', 'public_photos', true);
|
|
84
74
|
return value !== false && value !== 'false';
|
|
@@ -173,7 +163,13 @@ export const readMedia = async storedId => {
|
|
|
173
163
|
const { getMediaAsset } = await load('kempo-media');
|
|
174
164
|
const [error, asset] = await getMediaAsset(rawId);
|
|
175
165
|
if(error) return [error, null];
|
|
176
|
-
|
|
166
|
+
// The asset's id is its kempo-files file id, and its bytes live in kempo-files
|
|
167
|
+
const { getFile, filePath } = await load('kempo-files');
|
|
168
|
+
const [fileError, file] = await getFile(asset.fileId);
|
|
169
|
+
if(fileError) return [fileError, null];
|
|
170
|
+
const [pathError, absolute] = await filePath(file);
|
|
171
|
+
if(pathError) return [pathError, null];
|
|
172
|
+
return [null, { name: asset.originalName, kind: asset.kind, alt: asset.altText ?? '', bytes: await readFile(absolute) }];
|
|
177
173
|
}
|
|
178
174
|
} catch {
|
|
179
175
|
return [{ code: 404, msg: 'The file could not be read' }, null];
|
|
@@ -181,8 +177,6 @@ export const readMedia = async storedId => {
|
|
|
181
177
|
return [{ code: 409, msg: 'The library holding this file is not installed' }, null];
|
|
182
178
|
};
|
|
183
179
|
|
|
184
|
-
const MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', pdf: 'application/pdf' };
|
|
185
|
-
|
|
186
180
|
/*
|
|
187
181
|
Stores a file in whichever library new uploads go to (the same choice as an upload from the form), on
|
|
188
182
|
behalf of `userId`, and resolves to [null, storedId]. The caller is responsible for having checked who
|
|
@@ -212,16 +206,12 @@ export const storeMedia = async ({ name, bytes, altText = '', userId }) => {
|
|
|
212
206
|
}
|
|
213
207
|
return [{ code: 409, msg: 'No free file name could be found' }, null];
|
|
214
208
|
}
|
|
215
|
-
const {
|
|
209
|
+
const { uploadMediaAsset } = await load('kempo-media');
|
|
216
210
|
const [, maxMb] = await getSetting('kempo-media', 'max_upload_size_mb', 250);
|
|
217
|
-
const [,
|
|
218
|
-
|
|
219
|
-
rootDir: join(process.cwd(), 'public'), filename: safe, data, mimeType: MIME[extensionOf(safe)] ?? 'application/octet-stream',
|
|
220
|
-
altText, uploadedBy: userId, maxBytes: Number(maxMb) * 1024 * 1024, thumbnailMaxDimension: Number(thumbnailMaxDimension),
|
|
211
|
+
const [error, asset] = await uploadMediaAsset({
|
|
212
|
+
filename: safe, data, altText, uploadedBy: userId, maxBytes: Number(maxMb) * 1024 * 1024, public: await publicPhotos(),
|
|
221
213
|
});
|
|
222
|
-
|
|
223
|
-
const [recordError, row] = await createMediaAsset(asset);
|
|
224
|
-
return recordError ? [recordError, null] : [null, row.id];
|
|
214
|
+
return error ? [error, null] : [null, asset.id];
|
|
225
215
|
} catch {
|
|
226
216
|
return [{ code: 500, msg: 'The file could not be stored' }, null];
|
|
227
217
|
}
|
package/update.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sql } from 'drizzle-orm';
|
|
2
2
|
import db from 'kempo/server/db/index.js';
|
|
3
|
-
import { getSetting, setSetting } from 'kempo/server/sdk.js';
|
|
3
|
+
import { getSetting, setSetting, deleteSetting } from 'kempo/server/sdk.js';
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
6
|
kempo creates tables that are new in an update but never alters ones that already exist, so a
|
|
@@ -8,6 +8,9 @@ import { getSetting, setSetting } from 'kempo/server/sdk.js';
|
|
|
8
8
|
this on a database that already has the change is harmless.
|
|
9
9
|
*/
|
|
10
10
|
export default async () => {
|
|
11
|
+
// Next: media_provider is retired. kempo-media sits on kempo-files, so new uploads go through it whenever it is installed.
|
|
12
|
+
await deleteSetting('kempo-inventory', 'media_provider');
|
|
13
|
+
|
|
11
14
|
// 0.15: items and categories can be owned by an extension ('' = managed by people, as before)
|
|
12
15
|
await db.execute(sql`ALTER TABLE "kempoInventoryItem" ADD COLUMN IF NOT EXISTS "owner" text NOT NULL DEFAULT ''`);
|
|
13
16
|
await db.execute(sql`ALTER TABLE "kempoInventoryCategory" ADD COLUMN IF NOT EXISTS "owner" text NOT NULL DEFAULT ''`);
|