kempo-inventory 0.5.7 → 0.5.9
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/package.json
CHANGED
|
@@ -45,6 +45,15 @@
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/* The category is a built-in property of every item; '' means uncategorised. */
|
|
48
|
+
/* Only web addresses become links; anything else (a javascript: or data: address, say) shows as plain text. */
|
|
49
|
+
function isWebLink(value){
|
|
50
|
+
try {
|
|
51
|
+
const { protocol } = new URL(String(value).trim());
|
|
52
|
+
return protocol === 'http:' || protocol === 'https:';
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
48
57
|
function categoryOf(item){ return (item.category ?? '').trim(); }
|
|
49
58
|
function categoryHref(name){ return name ? `/category?name=${encodeURIComponent(name)}` : '/category?uncategorised=1'; }
|
|
50
59
|
function tagChips(tags){ return tags?.length ? html`<div class="tag-list">${tags.map(tag => html`<a class="tag-chip" href="/search?tag=${encodeURIComponent(tag)}">${tag}</a>`)}</div>` : ''; }
|
|
@@ -112,8 +121,8 @@
|
|
|
112
121
|
? 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
122
|
: f.type === 'item' && linked[item.fields[f.key]]
|
|
114
123
|
? html`<a href="/items/${item.fields[f.key]}">${displayValue(f, item.fields[f.key], context)}</a>`
|
|
115
|
-
: f.type === 'url'
|
|
116
|
-
? html`<a href=${item.fields[f.key]} rel="noopener">${item.fields[f.key]}</a>`
|
|
124
|
+
: f.type === 'url' && isWebLink(item.fields[f.key])
|
|
125
|
+
? html`<a href=${item.fields[f.key]} target="_blank" rel="noopener">${item.fields[f.key]}</a>`
|
|
117
126
|
: f.type === 'longtext'
|
|
118
127
|
? html`<span style="white-space: pre-wrap;">${item.fields[f.key]}</span>`
|
|
119
128
|
: displayValue(f, item.fields[f.key], context)}</dd>`)}
|
|
@@ -24,10 +24,15 @@ 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
|
|
|
31
|
+
/* A javascript: link runs code when clicked. Browsers ignore spaces and control characters inside the scheme, so they are allowed for. */
|
|
32
|
+
const SCRIPT_SCHEME = /^[\s\x00-\x1f\x7f]*j[\s\x00-\x1f\x7f]*a[\s\x00-\x1f\x7f]*v[\s\x00-\x1f\x7f]*a[\s\x00-\x1f\x7f]*s[\s\x00-\x1f\x7f]*c[\s\x00-\x1f\x7f]*r[\s\x00-\x1f\x7f]*i[\s\x00-\x1f\x7f]*p[\s\x00-\x1f\x7f]*t[\s\x00-\x1f\x7f]*:/i;
|
|
33
|
+
export const hasScriptScheme = value => SCRIPT_SCHEME.test(String(value));
|
|
34
|
+
export const stripScriptScheme = value => String(value).replace(SCRIPT_SCHEME, '');
|
|
35
|
+
|
|
31
36
|
/*
|
|
32
37
|
A `media` value is a list of ids, each saying where the file lives. A bare id is a kempo-media
|
|
33
38
|
asset; "files:<id>" is a kempo-files file.
|
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, hasScriptScheme, stripScriptScheme } from './fieldTypes.js';
|
|
7
7
|
import { mediaAvailable } from './media.js';
|
|
8
8
|
import { EVENTS, guard, notify } from './hooks.js';
|
|
9
9
|
|
|
@@ -11,6 +11,24 @@ 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 original = row.data[key];
|
|
24
|
+
if(original === '' || original === null) continue;
|
|
25
|
+
const cleaned = hasScriptScheme(original) ? stripScriptScheme(original) : original;
|
|
26
|
+
const [invalid, address] = coerceValue({ type: 'url', label }, cleaned);
|
|
27
|
+
const value = !invalid && address ? address : cleaned;
|
|
28
|
+
if(value !== original) rewrites.push({ id: row.id, data: { ...row.data, [key]: value } });
|
|
29
|
+
}
|
|
30
|
+
return rewrites;
|
|
31
|
+
};
|
|
14
32
|
/*
|
|
15
33
|
Properties an extension's own field accepts on update, and what anyone else (the admin UI) may
|
|
16
34
|
adjust on a field an extension owns. Key and ownership never change after creation, the scope
|
|
@@ -160,6 +178,9 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
160
178
|
}
|
|
161
179
|
if(!Object.keys(updates).length) return [{ code: 400, msg: 'No changes provided' }, null];
|
|
162
180
|
|
|
181
|
+
/* Text to a URL: values that are web addresses are kept as their full address; anything else stays text. */
|
|
182
|
+
const rewrites = targetType === 'url' && existing.type !== 'url' ? await addressesOf(key, existing.label) : [];
|
|
183
|
+
|
|
163
184
|
const refused = await guard(EVENTS.fieldBeforeUpdate, { field: existing, changes: updates, actor: owner ?? '' });
|
|
164
185
|
if(refused) return [refused, null];
|
|
165
186
|
|
|
@@ -172,6 +193,9 @@ export const updateField = async (key, changes = {}, { owner = null, category =
|
|
|
172
193
|
.values({ id: newId(), key: targetScope, name: targetName, image: null, created: now, updated: now })
|
|
173
194
|
.onConflictDoNothing();
|
|
174
195
|
}
|
|
196
|
+
for(const rewrite of rewrites){
|
|
197
|
+
await tx.update(kempoInventoryItem).set({ data: rewrite.data }).where(eq(kempoInventoryItem.id, rewrite.id));
|
|
198
|
+
}
|
|
175
199
|
const [updated] = await tx.update(kempoInventoryField).set(updates)
|
|
176
200
|
.where(and(eq(kempoInventoryField.key, key), eq(kempoInventoryField.category, existing.category))).returning();
|
|
177
201
|
return updated;
|