kempo-inventory 0.5.6 → 0.5.8
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
CHANGED
|
@@ -23,6 +23,7 @@ Users in the `kempo-inventory:admin` group add fields from **Inventory > Fields*
|
|
|
23
23
|
| Type | Holds |
|
|
24
24
|
|---|---|
|
|
25
25
|
| `text` | Short text (up to 1,000 characters) |
|
|
26
|
+
| `url` | A web address, shown as a link on the item page. Without a scheme `https://` is added; only `http` and `https` are accepted |
|
|
26
27
|
| `longtext` | Longer text (up to 20,000 characters) |
|
|
27
28
|
| `number` | Any number |
|
|
28
29
|
| `boolean` | Yes / no |
|
package/package.json
CHANGED
|
@@ -354,6 +354,9 @@ export const fieldTemplate = (field, value, { linked = {}, media = {}, ownId = n
|
|
|
354
354
|
case 'longtext':
|
|
355
355
|
control = html`<textarea id=${id} class="full" rows="3" .value=${value ?? ''}></textarea>`;
|
|
356
356
|
break;
|
|
357
|
+
case 'url':
|
|
358
|
+
control = html`<input type="url" id=${id} class="full" placeholder="https://" .value=${value ?? ''}>`;
|
|
359
|
+
break;
|
|
357
360
|
case 'number':
|
|
358
361
|
control = html`<input type="number" step="any" id=${id} class="full" .value=${value ?? ''}>`;
|
|
359
362
|
break;
|
|
@@ -112,7 +112,9 @@
|
|
|
112
112
|
? html`<span style="display: inline-block; width: 4rem; height: 4rem; vertical-align: middle; margin-right: var(--spacer_h); border: 1px solid var(--c_border); border-radius: var(--radius); background: ${item.fields[f.key]};"></span>${item.fields[f.key]}`
|
|
113
113
|
: f.type === 'item' && linked[item.fields[f.key]]
|
|
114
114
|
? html`<a href="/items/${item.fields[f.key]}">${displayValue(f, item.fields[f.key], context)}</a>`
|
|
115
|
-
: f.type === '
|
|
115
|
+
: f.type === 'url'
|
|
116
|
+
? html`<a href=${item.fields[f.key]} target="_blank" rel="noopener">${item.fields[f.key]}</a>`
|
|
117
|
+
: f.type === 'longtext'
|
|
116
118
|
? html`<span style="white-space: pre-wrap;">${item.fields[f.key]}</span>`
|
|
117
119
|
: displayValue(f, item.fields[f.key], context)}</dd>`)}
|
|
118
120
|
</dl>` : ''}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { categoryKey } from './categoryLogic.js';
|
|
7
7
|
import { parseRatio } from './ratio.js';
|
|
8
8
|
|
|
9
|
-
export const FIELD_TYPES = ['text', 'longtext', 'number', 'boolean', 'date', 'color', 'rating', 'select', 'item', 'media'];
|
|
9
|
+
export const FIELD_TYPES = ['text', 'url', 'longtext', 'number', 'boolean', 'date', 'color', 'rating', 'select', 'item', 'media'];
|
|
10
10
|
|
|
11
11
|
/*
|
|
12
12
|
Types that depend on another extension being installed. They are always valid in a definition,
|
|
@@ -24,7 +24,7 @@ export const MAX_IMAGE_EDGE = 8000;
|
|
|
24
24
|
else, such as text to a number, could orphan existing values, so it is refused: delete the field
|
|
25
25
|
and make a new one instead.
|
|
26
26
|
*/
|
|
27
|
-
export const CONVERSIONS = { select: ['text'], text: ['longtext'] };
|
|
27
|
+
export const CONVERSIONS = { select: ['text'], text: ['longtext', 'url'] };
|
|
28
28
|
|
|
29
29
|
export const canConvert = (from, to) => from === to || (CONVERSIONS[from] ?? []).includes(to);
|
|
30
30
|
|
|
@@ -47,6 +47,7 @@ export const KEY_PATTERN = /^[a-z][a-zA-Z0-9]{0,39}$/;
|
|
|
47
47
|
|
|
48
48
|
const MAX_TEXT = 1000;
|
|
49
49
|
const MAX_LONGTEXT = 20000;
|
|
50
|
+
const MAX_URL = 2000;
|
|
50
51
|
|
|
51
52
|
export const isEmpty = value => value === undefined || value === null || value === '';
|
|
52
53
|
|
|
@@ -136,6 +137,16 @@ export const coerceValue = (field, raw) => {
|
|
|
136
137
|
if(!value) return [null, null];
|
|
137
138
|
return value.length > MAX_LONGTEXT ? bad(`must be ${MAX_LONGTEXT} characters or fewer`) : [null, value];
|
|
138
139
|
}
|
|
140
|
+
case 'url': {
|
|
141
|
+
/* Without a scheme https:// is added. Only http and https are kept, so a link can never run a script. */
|
|
142
|
+
const text = String(raw).trim();
|
|
143
|
+
if(!text) return [null, null];
|
|
144
|
+
const address = /^[a-z][a-z0-9+.-]*:/i.test(text) ? text : `https://${text}`;
|
|
145
|
+
if(address.length > MAX_URL) return bad(`must be ${MAX_URL} characters or fewer`);
|
|
146
|
+
let parsed;
|
|
147
|
+
try { parsed = new URL(address); } catch { return bad('must be a web address, such as https://example.com'); }
|
|
148
|
+
return ['http:', 'https:'].includes(parsed.protocol) ? [null, address] : bad('must be a web address, such as https://example.com');
|
|
149
|
+
}
|
|
139
150
|
case 'number': {
|
|
140
151
|
const value = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
141
152
|
return Number.isFinite(value) ? [null, value] : bad('must be a number');
|
package/server/utils/fields.js
CHANGED
|
@@ -3,7 +3,7 @@ import { eq, and, asc, sql } from 'drizzle-orm';
|
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
import { kempoInventoryField, kempoInventoryItem, kempoInventoryCategory } from '../db/schema.js';
|
|
5
5
|
import { categoryKey, tidyCategory, MAX_CATEGORY } from './categoryLogic.js';
|
|
6
|
-
import { normalizeFieldDefinition, OPTIONAL_TYPES, canConvert } from './fieldTypes.js';
|
|
6
|
+
import { normalizeFieldDefinition, OPTIONAL_TYPES, canConvert, coerceValue } from './fieldTypes.js';
|
|
7
7
|
import { mediaAvailable } from './media.js';
|
|
8
8
|
import { EVENTS, guard, notify } from './hooks.js';
|
|
9
9
|
|
|
@@ -11,6 +11,22 @@ const newId = () => crypto.randomBytes(8).toString('hex');
|
|
|
11
11
|
|
|
12
12
|
const isUniqueViolation = error => (error?.code || error?.cause?.code) === '23505';
|
|
13
13
|
|
|
14
|
+
/*
|
|
15
|
+
The values items hold for a key, rewritten as full web addresses where they are one. Anything else is left
|
|
16
|
+
as it is (it still shows as text, and is not made into a link).
|
|
17
|
+
*/
|
|
18
|
+
const addressesOf = async (key, label) => {
|
|
19
|
+
const rows = await db.select({ id: kempoInventoryItem.id, data: kempoInventoryItem.data }).from(kempoInventoryItem)
|
|
20
|
+
.where(sql`${kempoInventoryItem.data}->>${key} IS NOT NULL`);
|
|
21
|
+
const rewrites = [];
|
|
22
|
+
for(const row of rows){
|
|
23
|
+
const value = row.data[key];
|
|
24
|
+
if(value === '' || value === null) continue;
|
|
25
|
+
const [invalid, address] = coerceValue({ type: 'url', label }, value);
|
|
26
|
+
if(!invalid && address && address !== value) rewrites.push({ id: row.id, data: { ...row.data, [key]: address } });
|
|
27
|
+
}
|
|
28
|
+
return rewrites;
|
|
29
|
+
};
|
|
14
30
|
/*
|
|
15
31
|
Properties an extension's own field accepts on update, and what anyone else (the admin UI) may
|
|
16
32
|
adjust on a field an extension owns. Key and ownership never change after creation, the scope
|
|
@@ -160,6 +176,9 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
160
176
|
}
|
|
161
177
|
if(!Object.keys(updates).length) return [{ code: 400, msg: 'No changes provided' }, null];
|
|
162
178
|
|
|
179
|
+
/* Text to a URL: values that are web addresses are kept as their full address; anything else stays text. */
|
|
180
|
+
const rewrites = targetType === 'url' && existing.type !== 'url' ? await addressesOf(key, existing.label) : [];
|
|
181
|
+
|
|
163
182
|
const refused = await guard(EVENTS.fieldBeforeUpdate, { field: existing, changes: updates, actor: owner ?? '' });
|
|
164
183
|
if(refused) return [refused, null];
|
|
165
184
|
|
|
@@ -172,6 +191,9 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
172
191
|
.values({ id: newId(), key: targetScope, name: targetName, image: null, created: now, updated: now })
|
|
173
192
|
.onConflictDoNothing();
|
|
174
193
|
}
|
|
194
|
+
for(const rewrite of rewrites){
|
|
195
|
+
await tx.update(kempoInventoryItem).set({ data: rewrite.data }).where(eq(kempoInventoryItem.id, rewrite.id));
|
|
196
|
+
}
|
|
175
197
|
const [updated] = await tx.update(kempoInventoryField).set(updates)
|
|
176
198
|
.where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category))).returning();
|
|
177
199
|
return updated;
|