@yuneta/gobj-ui 7.23.58 → 7.23.60

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.
@@ -0,0 +1,361 @@
1
+ /***********************************************************************
2
+ * yui_file_field.js
3
+ *
4
+ * The control that PICKS a file for a treedb `file` column, and the
5
+ * manifest that carries its bytes beside the record.
6
+ *
7
+ * A column flagged ['fkey','file'] holds an fkey into the treedb's
8
+ * system topic `__assets__`: the bytes live on disk under the treedb,
9
+ * the index in memory, and the record names the asset by its sha256.
10
+ * So this control does NOT put a file in the record. It keeps the
11
+ * `File` the person picked and hands up an IDENTITY, and the bytes
12
+ * travel beside the record in a `__files__` manifest keyed by column.
13
+ *
14
+ * Three things about that are not obvious and are the whole reason
15
+ * this module is separate:
16
+ *
17
+ * - **The file is read at SAVE, not at pick.** An `<input type=file>`
18
+ * hands over a `File`, which is a REFERENCE and not the bytes.
19
+ * Picking shows a name and a size and reads nothing, so cancelling
20
+ * a form does not mean a 40 MB video was read for nothing.
21
+ * - **A `File` cannot travel in a kw.** A kw is plain json -- the
22
+ * machine trace serialises it -- and a `File` is a host object. The
23
+ * form KEEPS it and the host asks for it through a local method.
24
+ * - **Reading is a promise, so saving stops being synchronous**, and
25
+ * a resolved promise is an OS notification that must enter the
26
+ * machine as an EVENT, never as a chain of callbacks. That is the
27
+ * HOST's business; what lives here is the promise it awaits.
28
+ *
29
+ * The pure half (manifest, size, sha256, the name of a picked file)
30
+ * is tested without a DOM; the DOM half builds the control.
31
+ *
32
+ * Using Bulma Framework (https://bulma.io)
33
+ *
34
+ * Copyright (c) 2026, ArtGins.
35
+ * All Rights Reserved.
36
+ ***********************************************************************/
37
+
38
+ import {
39
+ createElement2, log_error
40
+ } from "@yuneta/gobj-js";
41
+
42
+ import "./yui_file_field.css";
43
+
44
+ /*
45
+ * What the treedb's default ceiling accepts, as an `accept` attribute:
46
+ * a hint to the file dialog, never a check. The check is treedb's, on
47
+ * the BYTES, at the door -- a browser filter is a convenience and a
48
+ * client that means to lie walks past it.
49
+ */
50
+ const YUI_FILE_ACCEPT = [
51
+ "image/jpeg", "image/png", "image/webp", "image/gif",
52
+ "application/pdf",
53
+ "video/mp4", "video/webm", "video/quicktime", "video/ogg", "video/x-matroska",
54
+ "audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm", "audio/flac"
55
+ ].join(",");
56
+
57
+
58
+ /***************************
59
+ * The pure half
60
+ ***************************/
61
+
62
+
63
+
64
+
65
+ /***************************************************************************
66
+ * A size a person reads. Bytes up to 1 KB, then one decimal.
67
+ ***************************************************************************/
68
+ function yui_file_size_label(bytes)
69
+ {
70
+ let n = Number(bytes);
71
+ if(!isFinite(n) || n < 0) {
72
+ return "";
73
+ }
74
+ if(n < 1024) {
75
+ return `${n} B`;
76
+ }
77
+ const units = ["KB", "MB", "GB", "TB"];
78
+ let i = -1;
79
+ do {
80
+ n = n / 1024;
81
+ i++;
82
+ } while(n >= 1024 && i < units.length - 1);
83
+ return `${n.toFixed(1)} ${units[i]}`;
84
+ }
85
+
86
+ /***************************************************************************
87
+ * The sha256 of an ArrayBuffer, lowercase hex, or null.
88
+ *
89
+ * NULL is a legal answer and not a failure: `crypto.subtle` exists only
90
+ * in a SECURE CONTEXT, so a dev server on plain http has none. Then the
91
+ * record carries no id and treedb fills it -- the write path hashes what
92
+ * arrives anyway, because it never trusts the client's id. What the hash
93
+ * buys here is an integrity check the backend can make (a wrong id with
94
+ * good bytes is refused), not the identity itself.
95
+ ***************************************************************************/
96
+ async function yui_file_sha256(buffer)
97
+ {
98
+ const subtle = (typeof crypto !== "undefined") && crypto.subtle;
99
+ if(!subtle) {
100
+ return null;
101
+ }
102
+ try {
103
+ const digest = await subtle.digest("SHA-256", buffer);
104
+ return Array.from(new Uint8Array(digest))
105
+ .map((b) => b.toString(16).padStart(2, "0"))
106
+ .join("");
107
+ } catch (e) {
108
+ /*
109
+ * Said, not swallowed: without this the record simply carries no
110
+ * id, which is indistinguishable from an insecure context, and
111
+ * the reason never reaches anybody.
112
+ */
113
+ log_error(`cannot hash the picked file: ${e && e.message? e.message: e}`);
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /***************************************************************************
119
+ * base64 of an ArrayBuffer.
120
+ *
121
+ * In chunks because `String.fromCharCode(...bytes)` spreads the whole
122
+ * array onto the call stack, and a few hundred KB is already a
123
+ * RangeError -- which for a file picker is every file that matters.
124
+ ***************************************************************************/
125
+ function yui_array_buffer_to_base64(buffer)
126
+ {
127
+ const bytes = new Uint8Array(buffer);
128
+ const CHUNK = 0x8000;
129
+ let binary = "";
130
+ for(let i = 0; i < bytes.length; i += CHUNK) {
131
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
132
+ }
133
+ return btoa(binary);
134
+ }
135
+
136
+ /***************************************************************************
137
+ * Read ONE picked file into what the manifest carries.
138
+ *
139
+ * -> {content64, content_type, original_name, size, id}
140
+ *
141
+ * `id` is the sha256 when it could be computed, and "" when it could
142
+ * not. Nothing else in here is optional: treedb checks the size on the
143
+ * base64 before decoding it, and the type against the BYTES.
144
+ ***************************************************************************/
145
+ async function yui_file_read(file)
146
+ {
147
+ const buffer = await file.arrayBuffer();
148
+ const id = await yui_file_sha256(buffer);
149
+ return {
150
+ content64: yui_array_buffer_to_base64(buffer),
151
+ content_type: file.type || "",
152
+ original_name: file.name || "",
153
+ size: file.size,
154
+ id: id || ""
155
+ };
156
+ }
157
+
158
+ /***************************************************************************
159
+ * The write a record with picked files becomes.
160
+ *
161
+ * picks {col: read} what yui_file_read() answered, per column
162
+ * record the record the form collected (MODIFIED: each picked
163
+ * column takes the id, or "" when there is none)
164
+ *
165
+ * -> {record, __files__}
166
+ *
167
+ * `__files__` is not a column: it is an instruction to the write path,
168
+ * consumed and dropped at the door. The bytes never go INSIDE the
169
+ * column -- a field that CARRIES the bytes and a field that KEEPS them
170
+ * are one word apart and 460 MB of RAM apart.
171
+ ***************************************************************************/
172
+ function yui_files_manifest(picks, record)
173
+ {
174
+ const out = record || {};
175
+ const manifest = {};
176
+
177
+ Object.keys(picks || {}).forEach((col) => {
178
+ const read = picks[col];
179
+ if(!read) {
180
+ return;
181
+ }
182
+ manifest[col] = {
183
+ content64: read.content64,
184
+ content_type: read.content_type,
185
+ original_name: read.original_name
186
+ };
187
+ /*
188
+ * The column takes the id, never the bytes. Empty when the
189
+ * browser could not hash: treedb then fills it from what arrives.
190
+ */
191
+ out[col] = read.id || "";
192
+ });
193
+
194
+ return {record: out, __files__: manifest};
195
+ }
196
+
197
+
198
+
199
+
200
+ /***************************
201
+ * The DOM half
202
+ ***************************/
203
+
204
+
205
+
206
+
207
+ /***************************************************************************
208
+ * What the control shows for what the column HOLDS today: the asset id,
209
+ * shortened. A sha256 in full is 64 characters of noise in a form.
210
+ ***************************************************************************/
211
+ function yui_file_id_label(id)
212
+ {
213
+ if(!id) {
214
+ return "";
215
+ }
216
+ return (id.length > 16)? `${id.slice(0, 8)}…${id.slice(-4)}`: id;
217
+ }
218
+
219
+ /***************************************************************************
220
+ * The control for a `file` column.
221
+ *
222
+ * name the column
223
+ * value the asset id the record holds today ("" when none)
224
+ * readonly draw it, do not let it change
225
+ * on_pick (file|null) -> void. NULL is "the person cleared it".
226
+ *
227
+ * Returns the `<div class="control">` content, and hangs the state the
228
+ * form reads off the returned element:
229
+ *
230
+ * $control.yui_file the picked File, or null
231
+ * $control.yui_file_value the id the column keeps ("" = cleared)
232
+ *
233
+ * The `<input type="file">` is hidden and driven by a button, because a
234
+ * bare file input cannot be styled and says "Sin archivos seleccionados"
235
+ * in the browser's own language, not the app's.
236
+ ***************************************************************************/
237
+ function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
238
+ {
239
+ const has_asset = !!value;
240
+
241
+ const $input = createElement2(["input", {
242
+ type: "file",
243
+ class: "FILE_INPUT",
244
+ name: `${name}__file`,
245
+ accept: accept || YUI_FILE_ACCEPT,
246
+ hidden: ""
247
+ }]);
248
+
249
+ const $pick = createElement2(["button", {
250
+ type: "button",
251
+ class: "FILE_PICK button is-small",
252
+ "data-i18n-title": "choose a file",
253
+ "aria-label": "choose a file"
254
+ }, [
255
+ ["span", {class: "FILE_PICK_ICON yi-upload"}],
256
+ /* The label stays: this control is alone in its row, so nothing
257
+ * competes for the width, and two bare icons side by side read as
258
+ * the same control. */
259
+ ["span", {class: "FILE_PICK_LABEL", i18n: "choose a file"}, "choose a file"]
260
+ ]]);
261
+
262
+ const $clear = createElement2(["button", {
263
+ type: "button",
264
+ class: "FILE_CLEAR button is-small",
265
+ "data-i18n-title": "remove the file",
266
+ "aria-label": "remove the file"
267
+ }, [
268
+ ["span", {class: "FILE_CLEAR_ICON yi-trash"}]
269
+ ]]);
270
+
271
+ const $name = createElement2(["span", {class: "FILE_NAME"}]);
272
+ const $size = createElement2(["span", {class: "FILE_SIZE"}]);
273
+ const $state = createElement2(["span", {class: "FILE_STATE"}]);
274
+
275
+ const $control = createElement2(["div", {class: "FILE_FIELD"}, [
276
+ $input, $pick, $name, $size, $state, $clear
277
+ ]]);
278
+
279
+ $control.yui_file = null;
280
+ $control.yui_file_value = value || "";
281
+
282
+ /*
283
+ * Three things it can be showing, and they are not the same thing:
284
+ * the asset the record already names, a file picked and not yet
285
+ * saved, and nothing at all.
286
+ */
287
+ const render = () => {
288
+ const picked = $control.yui_file;
289
+ const kept = $control.yui_file_value;
290
+
291
+ $name.textContent = picked? (picked.name || ""): yui_file_id_label(kept);
292
+ $size.textContent = picked? yui_file_size_label(picked.size): "";
293
+
294
+ $state.className = "FILE_STATE";
295
+ $state.removeAttribute("data-i18n");
296
+ $state.textContent = "";
297
+ if(picked) {
298
+ $state.classList.add("is-picked");
299
+ $state.setAttribute("data-i18n", "not saved yet");
300
+ $state.textContent = "not saved yet";
301
+ } else if(!kept) {
302
+ $state.classList.add("is-empty");
303
+ $state.setAttribute("data-i18n", "no file");
304
+ $state.textContent = "no file";
305
+ }
306
+
307
+ $clear.hidden = readonly || (!picked && !kept);
308
+ $pick.hidden = !!readonly;
309
+ };
310
+
311
+ if(!readonly) {
312
+ $pick.addEventListener("click", () => {
313
+ $input.click();
314
+ });
315
+ $input.addEventListener("change", () => {
316
+ /*
317
+ * Picking READS NOTHING. It takes the reference, shows the
318
+ * name and the size the `File` already carries, and stops.
319
+ */
320
+ const file = ($input.files && $input.files.length)? $input.files[0]: null;
321
+ $control.yui_file = file;
322
+ render();
323
+ if(on_pick) {
324
+ on_pick(file);
325
+ }
326
+ });
327
+ $clear.addEventListener("click", () => {
328
+ $control.yui_file = null;
329
+ $control.yui_file_value = "";
330
+ $input.value = "";
331
+ render();
332
+ if(on_pick) {
333
+ on_pick(null);
334
+ }
335
+ });
336
+ }
337
+
338
+ /*
339
+ * The form writes `yui_file` / `yui_file_value` straight onto the
340
+ * element -- when it loads a record, when it clears the form -- and
341
+ * writing a property fires nothing, so it has to be able to say
342
+ * "now draw what you are holding".
343
+ */
344
+ $control.yui_file_render = render;
345
+
346
+ render();
347
+
348
+ return {$control, $input, has_asset};
349
+ }
350
+
351
+
352
+ export {
353
+ YUI_FILE_ACCEPT,
354
+ yui_file_size_label,
355
+ yui_file_sha256,
356
+ yui_array_buffer_to_base64,
357
+ yui_file_read,
358
+ yui_files_manifest,
359
+ yui_file_id_label,
360
+ yui_file_control
361
+ };
@@ -0,0 +1,119 @@
1
+ /***********************************************************************
2
+ * yui_file_field.test.js
3
+ *
4
+ * The pure half of the `file` column control: the manifest that
5
+ * carries the bytes BESIDE the record, the base64 that must survive
6
+ * a file worth picking, and the hash that is allowed to be absent.
7
+ *
8
+ * Copyright (c) 2026, ArtGins.
9
+ * All Rights Reserved.
10
+ ***********************************************************************/
11
+ import { test, expect } from "vitest";
12
+ import {
13
+ yui_file_size_label,
14
+ yui_array_buffer_to_base64,
15
+ yui_files_manifest,
16
+ yui_file_id_label
17
+ } from "./yui_file_field.js";
18
+
19
+
20
+ /***************************************************************
21
+ * The manifest
22
+ ***************************************************************/
23
+ test("the bytes go BESIDE the record, and the column takes the id", () => {
24
+ const picks = {
25
+ foto: {
26
+ content64: "AAA=",
27
+ content_type: "image/png",
28
+ original_name: "dev-1.png",
29
+ size: 2,
30
+ id: "a".repeat(64)
31
+ }
32
+ };
33
+ const {record, __files__} = yui_files_manifest(picks, {id: "dev-1", name: "one"});
34
+
35
+ expect(record.foto).toBe("a".repeat(64));
36
+ expect(record.content64).toBeUndefined();
37
+ expect(record.__files__).toBeUndefined();
38
+ expect(__files__.foto.content64).toBe("AAA=");
39
+ expect(__files__.foto.original_name).toBe("dev-1.png");
40
+ /* size is treedb's to measure, never the client's to declare */
41
+ expect(__files__.foto.size).toBeUndefined();
42
+ expect(record.id).toBe("dev-1");
43
+ });
44
+
45
+ test("no hash: the column goes empty and treedb fills the id", () => {
46
+ const picks = {foto: {content64: "AAA=", content_type: "", original_name: "x", size: 2, id: ""}};
47
+ const {record, __files__} = yui_files_manifest(picks, {id: "dev-1"});
48
+ expect(record.foto).toBe("");
49
+ expect(__files__.foto.content64).toBe("AAA=");
50
+ });
51
+
52
+ test("two columns in one record make two entries", () => {
53
+ const picks = {
54
+ foto: {content64: "AA==", content_type: "image/png", original_name: "a", size: 1, id: "1"},
55
+ qr: {content64: "BB==", content_type: "image/jpeg", original_name: "b", size: 1, id: "2"}
56
+ };
57
+ const {record, __files__} = yui_files_manifest(picks, {id: "dev-2"});
58
+ expect(Object.keys(__files__).sort()).toEqual(["foto", "qr"]);
59
+ expect(record.foto).toBe("1");
60
+ expect(record.qr).toBe("2");
61
+ });
62
+
63
+ test("nothing picked leaves the record alone and the manifest empty", () => {
64
+ const {record, __files__} = yui_files_manifest({}, {id: "dev-1", foto: "kept"});
65
+ expect(record.foto).toBe("kept");
66
+ expect(Object.keys(__files__)).toHaveLength(0);
67
+ });
68
+
69
+ test("a null pick is not an entry", () => {
70
+ const {__files__} = yui_files_manifest({foto: null}, {id: "dev-1"});
71
+ expect(Object.keys(__files__)).toHaveLength(0);
72
+ });
73
+
74
+
75
+ /***************************************************************
76
+ * base64
77
+ ***************************************************************/
78
+ test("base64 survives a buffer that would blow the call stack spread", () => {
79
+ /*
80
+ * String.fromCharCode(...bytes) is a RangeError well under this, and
81
+ * a few hundred KB is every file a picker is for.
82
+ */
83
+ const buffer = new Uint8Array(300 * 1024);
84
+ for(let i = 0; i < buffer.length; i++) {
85
+ buffer[i] = i % 256;
86
+ }
87
+ const b64 = yui_array_buffer_to_base64(buffer.buffer);
88
+ expect(b64.length).toBe(Math.ceil(buffer.length / 3) * 4);
89
+ /* and it round-trips */
90
+ const back = atob(b64);
91
+ expect(back.length).toBe(buffer.length);
92
+ expect(back.charCodeAt(0)).toBe(0);
93
+ expect(back.charCodeAt(257)).toBe(1);
94
+ });
95
+
96
+ test("an empty buffer is an empty string, not a crash", () => {
97
+ expect(yui_array_buffer_to_base64(new Uint8Array(0).buffer)).toBe("");
98
+ });
99
+
100
+
101
+ /***************************************************************
102
+ * Labels
103
+ ***************************************************************/
104
+ test("a size a person reads", () => {
105
+ expect(yui_file_size_label(0)).toBe("0 B");
106
+ expect(yui_file_size_label(512)).toBe("512 B");
107
+ expect(yui_file_size_label(1024)).toBe("1.0 KB");
108
+ expect(yui_file_size_label(1536)).toBe("1.5 KB");
109
+ expect(yui_file_size_label(5 * 1024 * 1024)).toBe("5.0 MB");
110
+ expect(yui_file_size_label(-1)).toBe("");
111
+ expect(yui_file_size_label("nope")).toBe("");
112
+ });
113
+
114
+ test("a sha256 is shortened, a short id is not", () => {
115
+ const sha = "afbcdae99c628ddb7b12813d4ffced63cb6a1c6f52dcdbcef4e641dbe96debb6";
116
+ expect(yui_file_id_label(sha)).toBe("afbcdae9…ebb6");
117
+ expect(yui_file_id_label("short")).toBe("short");
118
+ expect(yui_file_id_label("")).toBe("");
119
+ });