@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.
@@ -5832,6 +5832,414 @@ function register_c_yui_wizard() {
5832
5832
  return create_gclass$17(GCLASS_NAME$17);
5833
5833
  }
5834
5834
  //#endregion
5835
+ //#region src/yui_file_field.js
5836
+ /***********************************************************************
5837
+ * yui_file_field.js
5838
+ *
5839
+ * The control that PICKS a file for a treedb `file` column, and the
5840
+ * manifest that carries its bytes beside the record.
5841
+ *
5842
+ * A column flagged ['fkey','file'] holds an fkey into the treedb's
5843
+ * system topic `__assets__`: the bytes live on disk under the treedb,
5844
+ * the index in memory, and the record names the asset by its sha256.
5845
+ * So this control does NOT put a file in the record. It keeps the
5846
+ * `File` the person picked and hands up an IDENTITY, and the bytes
5847
+ * travel beside the record in a `__files__` manifest keyed by column.
5848
+ *
5849
+ * Three things about that are not obvious and are the whole reason
5850
+ * this module is separate:
5851
+ *
5852
+ * - **The file is read at SAVE, not at pick.** An `<input type=file>`
5853
+ * hands over a `File`, which is a REFERENCE and not the bytes.
5854
+ * Picking shows a name and a size and reads nothing, so cancelling
5855
+ * a form does not mean a 40 MB video was read for nothing.
5856
+ * - **A `File` cannot travel in a kw.** A kw is plain json -- the
5857
+ * machine trace serialises it -- and a `File` is a host object. The
5858
+ * form KEEPS it and the host asks for it through a local method.
5859
+ * - **Reading is a promise, so saving stops being synchronous**, and
5860
+ * a resolved promise is an OS notification that must enter the
5861
+ * machine as an EVENT, never as a chain of callbacks. That is the
5862
+ * HOST's business; what lives here is the promise it awaits.
5863
+ *
5864
+ * The pure half (manifest, size, sha256, the name of a picked file)
5865
+ * is tested without a DOM; the DOM half builds the control.
5866
+ *
5867
+ * Using Bulma Framework (https://bulma.io)
5868
+ *
5869
+ * Copyright (c) 2026, ArtGins.
5870
+ * All Rights Reserved.
5871
+ ***********************************************************************/
5872
+ var YUI_FILE_ACCEPT = [
5873
+ "image/jpeg",
5874
+ "image/png",
5875
+ "image/webp",
5876
+ "image/gif",
5877
+ "application/pdf",
5878
+ "video/mp4",
5879
+ "video/webm",
5880
+ "video/quicktime",
5881
+ "video/ogg",
5882
+ "video/x-matroska",
5883
+ "audio/mpeg",
5884
+ "audio/mp4",
5885
+ "audio/ogg",
5886
+ "audio/wav",
5887
+ "audio/webm",
5888
+ "audio/flac"
5889
+ ].join(",");
5890
+ /***************************
5891
+ * The pure half
5892
+ ***************************/
5893
+ /***************************************************************************
5894
+ * A size a person reads. Bytes up to 1 KB, then one decimal.
5895
+ ***************************************************************************/
5896
+ function yui_file_size_label(bytes) {
5897
+ let n = Number(bytes);
5898
+ if (!isFinite(n) || n < 0) return "";
5899
+ if (n < 1024) return `${n} B`;
5900
+ const units = [
5901
+ "KB",
5902
+ "MB",
5903
+ "GB",
5904
+ "TB"
5905
+ ];
5906
+ let i = -1;
5907
+ do {
5908
+ n = n / 1024;
5909
+ i++;
5910
+ } while (n >= 1024 && i < units.length - 1);
5911
+ return `${n.toFixed(1)} ${units[i]}`;
5912
+ }
5913
+ /***************************************************************************
5914
+ * The sha256 of an ArrayBuffer, lowercase hex, or null.
5915
+ *
5916
+ * NULL is a legal answer and not a failure: `crypto.subtle` exists only
5917
+ * in a SECURE CONTEXT, so a dev server on plain http has none. Then the
5918
+ * record carries no id and treedb fills it -- the write path hashes what
5919
+ * arrives anyway, because it never trusts the client's id. What the hash
5920
+ * buys here is an integrity check the backend can make (a wrong id with
5921
+ * good bytes is refused), not the identity itself.
5922
+ ***************************************************************************/
5923
+ async function yui_file_sha256(buffer) {
5924
+ const subtle = typeof crypto !== "undefined" && crypto.subtle;
5925
+ if (!subtle) return null;
5926
+ try {
5927
+ const digest = await subtle.digest("SHA-256", buffer);
5928
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
5929
+ } catch (e) {
5930
+ (0, _yuneta_gobj_js.log_error)(`cannot hash the picked file: ${e && e.message ? e.message : e}`);
5931
+ return null;
5932
+ }
5933
+ }
5934
+ /***************************************************************************
5935
+ * base64 of an ArrayBuffer.
5936
+ *
5937
+ * In chunks because `String.fromCharCode(...bytes)` spreads the whole
5938
+ * array onto the call stack, and a few hundred KB is already a
5939
+ * RangeError -- which for a file picker is every file that matters.
5940
+ ***************************************************************************/
5941
+ function yui_array_buffer_to_base64(buffer) {
5942
+ const bytes = new Uint8Array(buffer);
5943
+ const CHUNK = 32768;
5944
+ let binary = "";
5945
+ for (let i = 0; i < bytes.length; i += CHUNK) binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
5946
+ return btoa(binary);
5947
+ }
5948
+ /***************************************************************************
5949
+ * Read ONE picked file into what the manifest carries.
5950
+ *
5951
+ * -> {content64, content_type, original_name, size, id}
5952
+ *
5953
+ * `id` is the sha256 when it could be computed, and "" when it could
5954
+ * not. Nothing else in here is optional: treedb checks the size on the
5955
+ * base64 before decoding it, and the type against the BYTES.
5956
+ ***************************************************************************/
5957
+ async function yui_file_read(file) {
5958
+ const buffer = await file.arrayBuffer();
5959
+ const id = await yui_file_sha256(buffer);
5960
+ return {
5961
+ content64: yui_array_buffer_to_base64(buffer),
5962
+ content_type: file.type || "",
5963
+ original_name: file.name || "",
5964
+ size: file.size,
5965
+ id: id || ""
5966
+ };
5967
+ }
5968
+ /***************************************************************************
5969
+ * The write a record with picked files becomes.
5970
+ *
5971
+ * picks {col: read} what yui_file_read() answered, per column
5972
+ * record the record the form collected (MODIFIED: each picked
5973
+ * column takes the id, or "" when there is none)
5974
+ *
5975
+ * -> {record, __files__}
5976
+ *
5977
+ * `__files__` is not a column: it is an instruction to the write path,
5978
+ * consumed and dropped at the door. The bytes never go INSIDE the
5979
+ * column -- a field that CARRIES the bytes and a field that KEEPS them
5980
+ * are one word apart and 460 MB of RAM apart.
5981
+ ***************************************************************************/
5982
+ function yui_files_manifest(picks, record) {
5983
+ const out = record || {};
5984
+ const manifest = {};
5985
+ Object.keys(picks || {}).forEach((col) => {
5986
+ const read = picks[col];
5987
+ if (!read) return;
5988
+ manifest[col] = {
5989
+ content64: read.content64,
5990
+ content_type: read.content_type,
5991
+ original_name: read.original_name
5992
+ };
5993
+ out[col] = read.id || "";
5994
+ });
5995
+ return {
5996
+ record: out,
5997
+ __files__: manifest
5998
+ };
5999
+ }
6000
+ /***************************
6001
+ * The DOM half
6002
+ ***************************/
6003
+ /***************************************************************************
6004
+ * What the control shows for what the column HOLDS today: the asset id,
6005
+ * shortened. A sha256 in full is 64 characters of noise in a form.
6006
+ ***************************************************************************/
6007
+ function yui_file_id_label(id) {
6008
+ if (!id) return "";
6009
+ return id.length > 16 ? `${id.slice(0, 8)}…${id.slice(-4)}` : id;
6010
+ }
6011
+ /***************************************************************************
6012
+ * The control for a `file` column.
6013
+ *
6014
+ * name the column
6015
+ * value the asset id the record holds today ("" when none)
6016
+ * readonly draw it, do not let it change
6017
+ * on_pick (file|null) -> void. NULL is "the person cleared it".
6018
+ *
6019
+ * Returns the `<div class="control">` content, and hangs the state the
6020
+ * form reads off the returned element:
6021
+ *
6022
+ * $control.yui_file the picked File, or null
6023
+ * $control.yui_file_value the id the column keeps ("" = cleared)
6024
+ *
6025
+ * The `<input type="file">` is hidden and driven by a button, because a
6026
+ * bare file input cannot be styled and says "Sin archivos seleccionados"
6027
+ * in the browser's own language, not the app's.
6028
+ ***************************************************************************/
6029
+ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
6030
+ const has_asset = !!value;
6031
+ const $input = (0, _yuneta_gobj_js.createElement2)(["input", {
6032
+ type: "file",
6033
+ class: "FILE_INPUT",
6034
+ name: `${name}__file`,
6035
+ accept: accept || YUI_FILE_ACCEPT,
6036
+ hidden: ""
6037
+ }]);
6038
+ const $pick = (0, _yuneta_gobj_js.createElement2)([
6039
+ "button",
6040
+ {
6041
+ type: "button",
6042
+ class: "FILE_PICK button is-small",
6043
+ "data-i18n-title": "choose a file",
6044
+ "aria-label": "choose a file"
6045
+ },
6046
+ [["span", { class: "FILE_PICK_ICON yi-upload" }], [
6047
+ "span",
6048
+ {
6049
+ class: "FILE_PICK_LABEL",
6050
+ i18n: "choose a file"
6051
+ },
6052
+ "choose a file"
6053
+ ]]
6054
+ ]);
6055
+ const $clear = (0, _yuneta_gobj_js.createElement2)([
6056
+ "button",
6057
+ {
6058
+ type: "button",
6059
+ class: "FILE_CLEAR button is-small",
6060
+ "data-i18n-title": "remove the file",
6061
+ "aria-label": "remove the file"
6062
+ },
6063
+ [["span", { class: "FILE_CLEAR_ICON yi-trash" }]]
6064
+ ]);
6065
+ const $name = (0, _yuneta_gobj_js.createElement2)(["span", { class: "FILE_NAME" }]);
6066
+ const $size = (0, _yuneta_gobj_js.createElement2)(["span", { class: "FILE_SIZE" }]);
6067
+ const $state = (0, _yuneta_gobj_js.createElement2)(["span", { class: "FILE_STATE" }]);
6068
+ const $control = (0, _yuneta_gobj_js.createElement2)([
6069
+ "div",
6070
+ { class: "FILE_FIELD" },
6071
+ [
6072
+ $input,
6073
+ $pick,
6074
+ $name,
6075
+ $size,
6076
+ $state,
6077
+ $clear
6078
+ ]
6079
+ ]);
6080
+ $control.yui_file = null;
6081
+ $control.yui_file_value = value || "";
6082
+ const render = () => {
6083
+ const picked = $control.yui_file;
6084
+ const kept = $control.yui_file_value;
6085
+ $name.textContent = picked ? picked.name || "" : yui_file_id_label(kept);
6086
+ $size.textContent = picked ? yui_file_size_label(picked.size) : "";
6087
+ $state.className = "FILE_STATE";
6088
+ $state.removeAttribute("data-i18n");
6089
+ $state.textContent = "";
6090
+ if (picked) {
6091
+ $state.classList.add("is-picked");
6092
+ $state.setAttribute("data-i18n", "not saved yet");
6093
+ $state.textContent = "not saved yet";
6094
+ } else if (!kept) {
6095
+ $state.classList.add("is-empty");
6096
+ $state.setAttribute("data-i18n", "no file");
6097
+ $state.textContent = "no file";
6098
+ }
6099
+ $clear.hidden = readonly || !picked && !kept;
6100
+ $pick.hidden = !!readonly;
6101
+ };
6102
+ if (!readonly) {
6103
+ $pick.addEventListener("click", () => {
6104
+ $input.click();
6105
+ });
6106
+ $input.addEventListener("change", () => {
6107
+ const file = $input.files && $input.files.length ? $input.files[0] : null;
6108
+ $control.yui_file = file;
6109
+ render();
6110
+ if (on_pick) on_pick(file);
6111
+ });
6112
+ $clear.addEventListener("click", () => {
6113
+ $control.yui_file = null;
6114
+ $control.yui_file_value = "";
6115
+ $input.value = "";
6116
+ render();
6117
+ if (on_pick) on_pick(null);
6118
+ });
6119
+ }
6120
+ $control.yui_file_render = render;
6121
+ render();
6122
+ return {
6123
+ $control,
6124
+ $input,
6125
+ has_asset
6126
+ };
6127
+ }
6128
+ //#endregion
6129
+ //#region src/yui_asset.js
6130
+ function ref_to_id(ref) {
6131
+ if (!ref) return null;
6132
+ if (typeof ref === "string") {
6133
+ if (ref.indexOf("^") < 0) return ref;
6134
+ const parts = ref.split("^");
6135
+ return parts.length >= 2 && parts[1] ? parts[1] : null;
6136
+ }
6137
+ if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
6138
+ return null;
6139
+ }
6140
+ /***************************************************************************
6141
+ * Every asset id a column names, in order. Always an array, possibly empty.
6142
+ ***************************************************************************/
6143
+ function yui_asset_ids(ref) {
6144
+ if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
6145
+ const id = ref_to_id(ref);
6146
+ return id ? [id] : [];
6147
+ }
6148
+ /***************************************************************************
6149
+ * The first asset id a column names, or null.
6150
+ ***************************************************************************/
6151
+ function yui_asset_id(ref) {
6152
+ const ids = yui_asset_ids(ref);
6153
+ return ids.length ? ids[0] : null;
6154
+ }
6155
+ /***************************************************************************
6156
+ * Turn a `get-asset` answer into something an element can load.
6157
+ *
6158
+ * Returns null when the answer carries neither -- which the caller must
6159
+ * treat as "missing", never as an empty src: an <img src=""> reloads the
6160
+ * PAGE in some browsers.
6161
+ ***************************************************************************/
6162
+ function yui_asset_src(answer) {
6163
+ if (!answer) return null;
6164
+ if (answer.mode === "url" && answer.url) return answer.url;
6165
+ if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
6166
+ return null;
6167
+ }
6168
+ /***************************************************************************
6169
+ * The marker for an asset that is not there.
6170
+ *
6171
+ * A missing image used to leave a broken box and no word about it, which
6172
+ * is indistinguishable from a slow one and from a bug. This says so, and
6173
+ * it says WHICH: `detail` is data (a name, a path) and is never
6174
+ * translated, while the label carries its i18n key so a language change
6175
+ * re-translates it.
6176
+ ***************************************************************************/
6177
+ function yui_asset_missing(detail, opts = {}) {
6178
+ const key = opts.key || "asset not available";
6179
+ const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
6180
+ "span",
6181
+ {
6182
+ class: "ASSET_MISSING_LABEL",
6183
+ i18n: key
6184
+ },
6185
+ key
6186
+ ]];
6187
+ if (detail) children.push([
6188
+ "span",
6189
+ { class: "ASSET_MISSING_DETAIL" },
6190
+ String(detail)
6191
+ ]);
6192
+ const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
6193
+ if (detail) attrs.title = String(detail);
6194
+ else attrs["data-i18n-title"] = key;
6195
+ return (0, _yuneta_gobj_js.createElement2)([
6196
+ "div",
6197
+ attrs,
6198
+ children
6199
+ ]);
6200
+ }
6201
+ /***************************************************************************
6202
+ * The element for one asset.
6203
+ *
6204
+ * The KIND comes from the content type the backend stored, not from the
6205
+ * name a person typed: video and audio are assets too, and an <img> whose
6206
+ * src is a film shows the broken box this module exists to remove.
6207
+ *
6208
+ * `detail` is what the missing marker shows -- pass the original name or
6209
+ * the source path, the thing a person can act on.
6210
+ ***************************************************************************/
6211
+ function yui_asset_element(answer, opts = {}) {
6212
+ const src = yui_asset_src(answer);
6213
+ if (!src) return yui_asset_missing(opts.detail, opts);
6214
+ const ct = (answer.content_type || "").toLowerCase();
6215
+ const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
6216
+ let el;
6217
+ if (ct.startsWith("video/")) el = (0, _yuneta_gobj_js.createElement2)(["video", {
6218
+ class: cls,
6219
+ controls: "",
6220
+ preload: "metadata"
6221
+ }]);
6222
+ else if (ct.startsWith("audio/")) el = (0, _yuneta_gobj_js.createElement2)(["audio", {
6223
+ class: cls,
6224
+ controls: "",
6225
+ preload: "metadata"
6226
+ }]);
6227
+ else {
6228
+ el = (0, _yuneta_gobj_js.createElement2)(["img", {
6229
+ class: cls,
6230
+ loading: "lazy"
6231
+ }]);
6232
+ el.alt = opts.alt || opts.detail || "";
6233
+ }
6234
+ el.src = src;
6235
+ el.onerror = () => {
6236
+ (0, _yuneta_gobj_js.log_error)(`asset could not be loaded: ${opts.detail || src}`);
6237
+ const $marker = yui_asset_missing(opts.detail, opts);
6238
+ if (el.parentNode) el.parentNode.replaceChild($marker, el);
6239
+ };
6240
+ return el;
6241
+ }
6242
+ //#endregion
5835
6243
  //#region src/yui_inputs.js
5836
6244
  /***************************************************************
5837
6245
  * The ✕ shows only when there is something to clear AND the field
@@ -6361,13 +6769,7 @@ var attrs_table$16 = [
6361
6769
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_checkbox", 0, false, "Show a first column with checkbox to select rows"),
6362
6770
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_rownum", 0, false, "Print first column with row number"),
6363
6771
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_BOOLEAN, "with_delete_row_btn", 0, false, "Print last column with delete row button"),
6364
- (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_JSON, "toolbar", 0, [
6365
- "save",
6366
- "undo",
6367
- "clear",
6368
- "copy",
6369
- "paste"
6370
- ], "Toolbar buttons to show, in order: save, undo, clear, copy, paste. [] hides the toolbar"),
6772
+ (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_JSON, "toolbar", 0, DEFAULT_TOOLBAR.slice(), "Toolbar buttons to show. The order inside each group is the order given here; which side each group takes is form_toolbar_plan.js. [] hides the toolbar"),
6371
6773
  (0, _yuneta_gobj_js.SDATA)(_yuneta_gobj_js.data_type_t.DTP_JSON, "tabulator_settings", 0, {
6372
6774
  layout: "fitDataFill",
6373
6775
  validationMode: "highlight",
@@ -6546,7 +6948,7 @@ function toolbar_button$1(gobj, name) {
6546
6948
  case "clear": return [
6547
6949
  "button",
6548
6950
  {
6549
- class: "button p-1",
6951
+ class: "button p-1 button-clear",
6550
6952
  title: "clear",
6551
6953
  "aria-label": "clear"
6552
6954
  },
@@ -6570,7 +6972,7 @@ function toolbar_button$1(gobj, name) {
6570
6972
  case "copy": return [
6571
6973
  "button",
6572
6974
  {
6573
- class: "button p-1",
6975
+ class: "button p-1 button-copy",
6574
6976
  title: "copy",
6575
6977
  "aria-label": "copy"
6576
6978
  },
@@ -6594,7 +6996,7 @@ function toolbar_button$1(gobj, name) {
6594
6996
  case "paste": return [
6595
6997
  "button",
6596
6998
  {
6597
- class: "button p-1",
6999
+ class: "button p-1 button-paste",
6598
7000
  title: "paste",
6599
7001
  "aria-label": "paste"
6600
7002
  },
@@ -6811,6 +7213,9 @@ function build_form_field_conf(gobj, field_desc) {
6811
7213
  field_conf.tag = "select2";
6812
7214
  field_conf.options = get_fkey_select_options(gobj, field_desc);
6813
7215
  break;
7216
+ case "file":
7217
+ field_conf.tag = "file";
7218
+ break;
6814
7219
  case "image":
6815
7220
  case "icon":
6816
7221
  case "string":
@@ -7496,6 +7901,17 @@ function create_form_field(gobj, $form, prefix, field_desc, { tag, inputType, na
7496
7901
  $control.appendChild($extend);
7497
7902
  break;
7498
7903
  }
7904
+ case "file":
7905
+ $extend = yui_file_control(gobj, {
7906
+ name,
7907
+ value: "",
7908
+ readonly,
7909
+ on_pick: function() {
7910
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_RECORD_CHANGED", {}, gobj);
7911
+ }
7912
+ }).$control;
7913
+ $control.appendChild($extend);
7914
+ break;
7499
7915
  default:
7500
7916
  $extend = (0, _yuneta_gobj_js.createElement2)([
7501
7917
  "strong",
@@ -7996,6 +8412,9 @@ function get_form_values(gobj, $form) {
7996
8412
  case "radio":
7997
8413
  value = getInputValue($input);
7998
8414
  break;
8415
+ case "file":
8416
+ value = $input.yui_file_value || "";
8417
+ break;
7999
8418
  case "jsoneditor":
8000
8419
  let jsoneditor = $input.jsoneditor;
8001
8420
  if (jsoneditor) {
@@ -8052,6 +8471,13 @@ function clear_data(gobj, $form) {
8052
8471
  else $input.value = null;
8053
8472
  }
8054
8473
  });
8474
+ $form.querySelectorAll(".FILE_FIELD").forEach(($file) => {
8475
+ $file.yui_file = null;
8476
+ $file.yui_file_value = "";
8477
+ let $picker = $file.querySelector(".FILE_INPUT");
8478
+ if ($picker) $picker.value = "";
8479
+ if ($file.yui_file_render) $file.yui_file_render();
8480
+ });
8055
8481
  $form.querySelectorAll(".yui-tabulator-table").forEach(($table) => {
8056
8482
  if ($table.tabulator.initialized) $table.tabulator.clearData();
8057
8483
  $table.querySelectorAll(".yui-tabulator-table-nested").forEach(($table_nested) => {
@@ -8121,6 +8547,11 @@ function set_form_values(gobj, template, $form, record) {
8121
8547
  value = treedb_value_2_form_value(gobj, field_desc, value);
8122
8548
  if (value === void 0) value = null;
8123
8549
  switch (tag) {
8550
+ case "file":
8551
+ $input.yui_file = null;
8552
+ $input.yui_file_value = yui_asset_id(value) || "";
8553
+ if ($input.yui_file_render) $input.yui_file_render();
8554
+ break;
8124
8555
  case "input":
8125
8556
  switch (input_type) {
8126
8557
  case "datetime-local":
@@ -8549,11 +8980,63 @@ function ac_timeout(gobj, event, kw, src) {
8549
8980
  /***************************
8550
8981
  * FSM
8551
8982
  ***************************/
8983
+ /************************************************************
8984
+ * Framework Method command
8985
+ ************************************************************/
8986
+ function mt_command_parser$2(gobj, command, kw, src) {
8987
+ switch (command) {
8988
+ case "get_picked_files": return cmd_get_picked_files(gobj, command, kw, src);
8989
+ default:
8990
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: command not found: ${command}`);
8991
+ return {
8992
+ result: -1,
8993
+ comment: `Command not found: ${command}`,
8994
+ schema: null,
8995
+ data: null
8996
+ };
8997
+ }
8998
+ }
8999
+ /************************************************************
9000
+ * The files a person picked and the record cannot carry.
9001
+ *
9002
+ * A `File` is a host object and a kw is plain json -- the machine trace
9003
+ * serialises it -- so the picked files never travel in an event. The
9004
+ * form KEEPS them and the HOST asks for them here, at save, and turns
9005
+ * them into the `__files__` manifest that rides beside the record.
9006
+ *
9007
+ * Answers `data: {col: File}`, empty when nothing was picked, which is
9008
+ * the ordinary case and not an error.
9009
+ ************************************************************/
9010
+ function cmd_get_picked_files(gobj, cmd, kw, src) {
9011
+ const files = {};
9012
+ const $container = (0, _yuneta_gobj_js.gobj_read_attr)(gobj, "$container");
9013
+ const $form = $container ? $container.querySelector("form") : null;
9014
+ if (!$form) {
9015
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: no form to read the picked files from`);
9016
+ return {
9017
+ result: -1,
9018
+ comment: "no form",
9019
+ schema: null,
9020
+ data: null
9021
+ };
9022
+ }
9023
+ $form.querySelectorAll(".yui-form-data-input").forEach(($input) => {
9024
+ if ($input.dataset.form_tag !== "file") return;
9025
+ if ($input.yui_file) files[$input.dataset.treedb_name] = $input.yui_file;
9026
+ });
9027
+ return {
9028
+ result: 0,
9029
+ comment: "",
9030
+ schema: null,
9031
+ data: files
9032
+ };
9033
+ }
8552
9034
  var gmt$16 = {
8553
9035
  mt_create: mt_create$16,
8554
9036
  mt_start: mt_start$15,
8555
9037
  mt_stop: mt_stop$15,
8556
- mt_destroy: mt_destroy$16
9038
+ mt_destroy: mt_destroy$16,
9039
+ mt_command_parser: mt_command_parser$2
8557
9040
  };
8558
9041
  /***************************************************************
8559
9042
  * Create the GClass
@@ -23996,6 +24479,9 @@ function create_tabulator(gobj) {
23996
24479
  vertAlign = "middle";
23997
24480
  colFormatter = icon_formatter;
23998
24481
  break;
24482
+ case "file":
24483
+ colFormatter = file_cell_formatter;
24484
+ break;
23999
24485
  case "object":
24000
24486
  case "dict":
24001
24487
  case "template":
@@ -24240,6 +24726,33 @@ function table__destroy(gobj) {
24240
24726
  * render a solid square -- a glyph nobody recognises rather
24241
24727
  * than a mistake somebody can fix.
24242
24728
  ************************************************************/
24729
+ /************************************************************
24730
+ * What a `file` column shows in a table cell: the asset it names.
24731
+ *
24732
+ * A sha256 in full is 64 characters of noise in a column, so it is
24733
+ * shortened and the whole of it is the tooltip. Empty is SAID, not left
24734
+ * blank -- a blank cell and a cell whose asset failed to load look the
24735
+ * same, and only one of them is normal.
24736
+ ************************************************************/
24737
+ function file_cell_formatter(cell, formatterParams, onRendered) {
24738
+ const id = yui_asset_id(cell.getValue());
24739
+ if (!id) return (0, _yuneta_gobj_js.createElement2)([
24740
+ "span",
24741
+ {
24742
+ class: "FILE_CELL is-empty",
24743
+ "data-i18n": "no file"
24744
+ },
24745
+ "no file"
24746
+ ]);
24747
+ return (0, _yuneta_gobj_js.createElement2)([
24748
+ "span",
24749
+ {
24750
+ class: "FILE_CELL",
24751
+ title: id
24752
+ },
24753
+ yui_file_id_label(id)
24754
+ ]);
24755
+ }
24243
24756
  function icon_formatter(cell) {
24244
24757
  let name = cell.getValue();
24245
24758
  if (!(0, _yuneta_gobj_js.is_string)(name) || (0, _yuneta_gobj_js.empty_string)(name)) return "";
@@ -24335,6 +24848,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
24335
24848
  ].join("");
24336
24849
  else value = "";
24337
24850
  break;
24851
+ case "file":
24338
24852
  case "fkey":
24339
24853
  let new_value = [];
24340
24854
  if (value) {
@@ -24859,6 +25373,7 @@ function transform__form_value_2_treedb_value(gobj, col, value, operation) {
24859
25373
  case "hook":
24860
25374
  value = null;
24861
25375
  break;
25376
+ case "file": break;
24862
25377
  case "fkey":
24863
25378
  value = build_fkey_ref(gobj, col, value);
24864
25379
  break;
@@ -25342,13 +25857,71 @@ function ac_paste_rows(gobj, event, kw, src) {
25342
25857
  function ac_form_save_record(gobj, event, kw, src) {
25343
25858
  if (refuse_if_readonly$2(gobj, event)) return -1;
25344
25859
  let mode = (0, _yuneta_gobj_js.gobj_read_str_attr)(src, "form_mode");
25345
- (0, _yuneta_gobj_js.gobj_publish_event)(gobj, mode === "create" ? "EV_CREATE_RECORD" : "EV_UPDATE_RECORD", {
25860
+ let picked = {};
25861
+ const answer = (0, _yuneta_gobj_js.gobj_command)(src, "get_picked_files", {}, gobj);
25862
+ if (answer && answer.result >= 0 && answer.data) picked = answer.data;
25863
+ const cols = Object.keys(picked);
25864
+ if (cols.length === 0) {
25865
+ publish_treedb_write(gobj, mode, kw);
25866
+ setTimeout(function() {
25867
+ close_form_dialog(gobj);
25868
+ }, 0);
25869
+ return 0;
25870
+ }
25871
+ Promise.all(cols.map((col) => {
25872
+ return yui_file_read(picked[col]).then((read) => {
25873
+ return {
25874
+ col,
25875
+ read
25876
+ };
25877
+ });
25878
+ })).then((reads) => {
25879
+ const picks = {};
25880
+ reads.forEach((r) => {
25881
+ picks[r.col] = r.read;
25882
+ });
25883
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_FILES_READ", {
25884
+ mode,
25885
+ record: kw,
25886
+ picks
25887
+ }, gobj);
25888
+ }).catch((e) => {
25889
+ (0, _yuneta_gobj_js.gobj_send_event)(gobj, "EV_FILES_FAILED", { reason: e && e.message ? e.message : String(e) }, gobj);
25890
+ });
25891
+ return 0;
25892
+ }
25893
+ /************************************************************
25894
+ * The one place a write of this view leaves for the app.
25895
+ ************************************************************/
25896
+ function publish_treedb_write(gobj, mode, record, jn_files) {
25897
+ const kw_write = {
25346
25898
  topic_name: (0, _yuneta_gobj_js.gobj_read_str_attr)(gobj, "topic_name"),
25347
- record: kw
25899
+ record
25900
+ };
25901
+ if (jn_files && Object.keys(jn_files).length > 0) kw_write.record.__files__ = jn_files;
25902
+ (0, _yuneta_gobj_js.gobj_publish_event)(gobj, mode === "create" ? "EV_CREATE_RECORD" : "EV_UPDATE_RECORD", kw_write);
25903
+ }
25904
+ /************************************************************
25905
+ * {mode, record, picks} -- the bytes are here now
25906
+ ************************************************************/
25907
+ function ac_files_read(gobj, event, kw, src) {
25908
+ const { record, __files__ } = yui_files_manifest(kw.picks, kw.record);
25909
+ publish_treedb_write(gobj, kw.mode, record, __files__);
25910
+ close_form_dialog(gobj);
25911
+ return 0;
25912
+ }
25913
+ /************************************************************
25914
+ * {reason} -- the browser could not read what was picked
25915
+ *
25916
+ * The form stays OPEN: the record is still there, and closing it would
25917
+ * throw away what the person typed for a failure that is not theirs.
25918
+ ************************************************************/
25919
+ function ac_files_failed(gobj, event, kw, src) {
25920
+ (0, _yuneta_gobj_js.log_error)(`${(0, _yuneta_gobj_js.gobj_short_name)(gobj)}: cannot read the picked file: ${kw.reason}`);
25921
+ yui_shell_confirm_ok(yui_shell_of(gobj), "cannot read the picked file", {
25922
+ t: i18next.t,
25923
+ ok_label: "accept"
25348
25924
  });
25349
- setTimeout(function() {
25350
- close_form_dialog(gobj);
25351
- }, 0);
25352
25925
  return 0;
25353
25926
  }
25354
25927
  /************************************************************
@@ -25867,6 +26440,16 @@ function create_gclass$5(gclass_name) {
25867
26440
  ac_form_save_record,
25868
26441
  null
25869
26442
  ],
26443
+ [
26444
+ "EV_FILES_READ",
26445
+ ac_files_read,
26446
+ null
26447
+ ],
26448
+ [
26449
+ "EV_FILES_FAILED",
26450
+ ac_files_failed,
26451
+ null
26452
+ ],
25870
26453
  [
25871
26454
  "EV_NEW_ROW",
25872
26455
  ac_new_row,
@@ -26001,6 +26584,8 @@ function create_gclass$5(gclass_name) {
26001
26584
  ["EV_NODE_DELETED", 0],
26002
26585
  ["EV_EDITION_MODE", 0],
26003
26586
  ["EV_SAVE_RECORD", 0],
26587
+ ["EV_FILES_READ", 0],
26588
+ ["EV_FILES_FAILED", 0],
26004
26589
  ["EV_NEW_ROW", 0],
26005
26590
  ["EV_DELETE_ROWS", 0],
26006
26591
  ["EV_COPY_ROWS", 0],
@@ -67513,120 +68098,6 @@ function register_c_yui_period() {
67513
68098
  return create_gclass(GCLASS_NAME);
67514
68099
  }
67515
68100
  //#endregion
67516
- //#region src/yui_asset.js
67517
- function ref_to_id(ref) {
67518
- if (!ref) return null;
67519
- if (typeof ref === "string") {
67520
- if (ref.indexOf("^") < 0) return ref;
67521
- const parts = ref.split("^");
67522
- return parts.length >= 2 && parts[1] ? parts[1] : null;
67523
- }
67524
- if (typeof ref === "object" && typeof ref.id === "string") return ref.id;
67525
- return null;
67526
- }
67527
- /***************************************************************************
67528
- * Every asset id a column names, in order. Always an array, possibly empty.
67529
- ***************************************************************************/
67530
- function yui_asset_ids(ref) {
67531
- if (Array.isArray(ref)) return ref.map(ref_to_id).filter((id) => !!id);
67532
- const id = ref_to_id(ref);
67533
- return id ? [id] : [];
67534
- }
67535
- /***************************************************************************
67536
- * The first asset id a column names, or null.
67537
- ***************************************************************************/
67538
- function yui_asset_id(ref) {
67539
- const ids = yui_asset_ids(ref);
67540
- return ids.length ? ids[0] : null;
67541
- }
67542
- /***************************************************************************
67543
- * Turn a `get-asset` answer into something an element can load.
67544
- *
67545
- * Returns null when the answer carries neither -- which the caller must
67546
- * treat as "missing", never as an empty src: an <img src=""> reloads the
67547
- * PAGE in some browsers.
67548
- ***************************************************************************/
67549
- function yui_asset_src(answer) {
67550
- if (!answer) return null;
67551
- if (answer.mode === "url" && answer.url) return answer.url;
67552
- if (answer.mode === "inline" && answer.content64) return `data:${answer.content_type || "application/octet-stream"};base64,${answer.content64}`;
67553
- return null;
67554
- }
67555
- /***************************************************************************
67556
- * The marker for an asset that is not there.
67557
- *
67558
- * A missing image used to leave a broken box and no word about it, which
67559
- * is indistinguishable from a slow one and from a bug. This says so, and
67560
- * it says WHICH: `detail` is data (a name, a path) and is never
67561
- * translated, while the label carries its i18n key so a language change
67562
- * re-translates it.
67563
- ***************************************************************************/
67564
- function yui_asset_missing(detail, opts = {}) {
67565
- const key = opts.key || "asset not available";
67566
- const children = [["span", { class: "ASSET_MISSING_ICON yi-triangle-exclamation" }], [
67567
- "span",
67568
- {
67569
- class: "ASSET_MISSING_LABEL",
67570
- i18n: key
67571
- },
67572
- key
67573
- ]];
67574
- if (detail) children.push([
67575
- "span",
67576
- { class: "ASSET_MISSING_DETAIL" },
67577
- String(detail)
67578
- ]);
67579
- const attrs = { class: `ASSET_MISSING ${opts.class || ""}`.trim() };
67580
- if (detail) attrs.title = String(detail);
67581
- else attrs["data-i18n-title"] = key;
67582
- return (0, _yuneta_gobj_js.createElement2)([
67583
- "div",
67584
- attrs,
67585
- children
67586
- ]);
67587
- }
67588
- /***************************************************************************
67589
- * The element for one asset.
67590
- *
67591
- * The KIND comes from the content type the backend stored, not from the
67592
- * name a person typed: video and audio are assets too, and an <img> whose
67593
- * src is a film shows the broken box this module exists to remove.
67594
- *
67595
- * `detail` is what the missing marker shows -- pass the original name or
67596
- * the source path, the thing a person can act on.
67597
- ***************************************************************************/
67598
- function yui_asset_element(answer, opts = {}) {
67599
- const src = yui_asset_src(answer);
67600
- if (!src) return yui_asset_missing(opts.detail, opts);
67601
- const ct = (answer.content_type || "").toLowerCase();
67602
- const cls = `ASSET_MEDIA ${opts.class || ""}`.trim();
67603
- let el;
67604
- if (ct.startsWith("video/")) el = (0, _yuneta_gobj_js.createElement2)(["video", {
67605
- class: cls,
67606
- controls: "",
67607
- preload: "metadata"
67608
- }]);
67609
- else if (ct.startsWith("audio/")) el = (0, _yuneta_gobj_js.createElement2)(["audio", {
67610
- class: cls,
67611
- controls: "",
67612
- preload: "metadata"
67613
- }]);
67614
- else {
67615
- el = (0, _yuneta_gobj_js.createElement2)(["img", {
67616
- class: cls,
67617
- loading: "lazy"
67618
- }]);
67619
- el.alt = opts.alt || opts.detail || "";
67620
- }
67621
- el.src = src;
67622
- el.onerror = () => {
67623
- (0, _yuneta_gobj_js.log_error)(`asset could not be loaded: ${opts.detail || src}`);
67624
- const $marker = yui_asset_missing(opts.detail, opts);
67625
- if (el.parentNode) el.parentNode.replaceChild($marker, el);
67626
- };
67627
- return el;
67628
- }
67629
- //#endregion
67630
68101
  //#region src/dev_machine_trace.js
67631
68102
  /***********************************************************************
67632
68103
  * dev_machine_trace.js
@@ -69312,6 +69783,7 @@ function setup_frontend_view(self) {
69312
69783
  //#endregion
69313
69784
  exports.EditControl = EditControl;
69314
69785
  exports.MarkerControl = MarkerControl;
69786
+ exports.YUI_FILE_ACCEPT = YUI_FILE_ACCEPT;
69315
69787
  exports.YUI_PERIODS = YUI_PERIODS;
69316
69788
  exports.YUI_PERIODS_DEFAULT = YUI_PERIODS_DEFAULT;
69317
69789
  exports.YUI_ROLLING = YUI_ROLLING;
@@ -69375,11 +69847,18 @@ exports.set_submit_state = set_submit_state;
69375
69847
  exports.setup_dev = setup_dev;
69376
69848
  exports.setup_frontend_view = setup_frontend_view;
69377
69849
  exports.toggleClasses = toggleClasses;
69850
+ exports.yui_array_buffer_to_base64 = yui_array_buffer_to_base64;
69378
69851
  exports.yui_asset_element = yui_asset_element;
69379
69852
  exports.yui_asset_id = yui_asset_id;
69380
69853
  exports.yui_asset_ids = yui_asset_ids;
69381
69854
  exports.yui_asset_missing = yui_asset_missing;
69382
69855
  exports.yui_asset_src = yui_asset_src;
69856
+ exports.yui_file_control = yui_file_control;
69857
+ exports.yui_file_id_label = yui_file_id_label;
69858
+ exports.yui_file_read = yui_file_read;
69859
+ exports.yui_file_sha256 = yui_file_sha256;
69860
+ exports.yui_file_size_label = yui_file_size_label;
69861
+ exports.yui_files_manifest = yui_files_manifest;
69383
69862
  exports.yui_is_dark = yui_is_dark;
69384
69863
  exports.yui_mount_service_view = yui_mount_service_view;
69385
69864
  exports.yui_shell_close_drawer = yui_shell_close_drawer;