@yuneta/gobj-ui 7.23.193 → 7.23.195

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.
@@ -7310,6 +7310,39 @@ function json_diff_rows(doc_a, doc_b) {
7310
7310
  return rows;
7311
7311
  }
7312
7312
  //#endregion
7313
+ //#region src/form_time_value.js
7314
+ /****************************************************************************
7315
+ * form_time_value.js
7316
+ *
7317
+ * A treedb `time` / `now` column is an epoch in SECONDS, and a form
7318
+ * shows it in a `datetime-local` input. These two convert between
7319
+ * them, and they must be each other's inverse: a writable time
7320
+ * column travels back on EVERY save of the record.
7321
+ *
7322
+ * Copyright (c) 2026, ArtGins.
7323
+ * All Rights Reserved.
7324
+ ****************************************************************************/
7325
+ /************************************************************
7326
+ * A Date -> the value of a `datetime-local` input, in local time,
7327
+ * WITH seconds: the input is built with `step: 1`, which is what
7328
+ * lets it hold them. Written as "YYYY-MM-DDTHH:mm" it lost them on
7329
+ * every save of any field (M26 of the 2026-09-21 review).
7330
+ ************************************************************/
7331
+ function date_to_datetime_local(value) {
7332
+ if (!(value instanceof Date) || isNaN(value.getTime())) return "";
7333
+ let pad = (n) => String(n).padStart(2, "0");
7334
+ return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
7335
+ }
7336
+ /************************************************************
7337
+ * The value of a `datetime-local` input -> epoch in seconds
7338
+ ************************************************************/
7339
+ function datetime_local_to_epoch(value) {
7340
+ if (!value) return null;
7341
+ const date = new Date(value);
7342
+ if (isNaN(date.getTime())) return null;
7343
+ return Math.floor(date.getTime() / 1e3);
7344
+ }
7345
+ //#endregion
7313
7346
  //#region src/c_yui_form.js
7314
7347
  /***********************************************************************
7315
7348
  * c_yui_form.js
@@ -8322,6 +8355,7 @@ function create_form_field(gobj, $form, prefix, field_desc, { tag, inputType, na
8322
8355
  type: inputType,
8323
8356
  placeholder
8324
8357
  };
8358
+ if (inputType === "datetime-local") attrs.step = 1;
8325
8359
  Object.assign(attrs, extras);
8326
8360
  if (readonly) attrs.readonly = "";
8327
8361
  else if (required) attrs.required = "";
@@ -9022,8 +9056,7 @@ function get_form_values(gobj, $form) {
9022
9056
  case "input":
9023
9057
  switch (input_type) {
9024
9058
  case "datetime-local":
9025
- const date = new Date($input.value);
9026
- value = Math.floor(date.getTime() / 1e3);
9059
+ value = datetime_local_to_epoch($input.value);
9027
9060
  break;
9028
9061
  default: value = getInputValue($input);
9029
9062
  }
@@ -9186,10 +9219,7 @@ function set_form_values(gobj, template, $form, record) {
9186
9219
  case "input":
9187
9220
  switch (input_type) {
9188
9221
  case "datetime-local":
9189
- if (value instanceof Date && !isNaN(value.getTime())) {
9190
- let pad = (n) => String(n).padStart(2, "0");
9191
- $input.value = `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
9192
- } else $input.value = "";
9222
+ $input.value = date_to_datetime_local(value);
9193
9223
  break;
9194
9224
  case "color":
9195
9225
  $input.value = convertColor(value);
@@ -25240,6 +25270,7 @@ function ac_create_record(gobj, event, kw, src) {
25240
25270
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25241
25271
  list_dict: true,
25242
25272
  create: true,
25273
+ create_only: true,
25243
25274
  autolink: true
25244
25275
  }, form_write) < 0) {
25245
25276
  answer_form_write(gobj, src, form_write, false);
@@ -26480,6 +26511,58 @@ function delete_impact(desc, records) {
26480
26511
  return out;
26481
26512
  }
26482
26513
  //#endregion
26514
+ //#region src/table_cell_text.js
26515
+ /****************************************************************************
26516
+ * table_cell_text.js
26517
+ *
26518
+ * What a treedb table cell hands Tabulator.
26519
+ *
26520
+ * Tabulator assigns a formatter's STRING result with `innerHTML`
26521
+ * (only its own `plaintext` formatter escapes), so a record's text
26522
+ * returned as a string is parsed as markup: `a<b and c>d` showed
26523
+ * as `ad`, and a field holding `<img src=x onerror=...>` ran it in
26524
+ * the operator's browser (M31 of the 2026-09-21 review). Record
26525
+ * data goes in as a TEXT NODE, and a cell built around it as DOM,
26526
+ * never as an HTML string with the data interpolated.
26527
+ *
26528
+ * Pure but for the `document` it is handed, so it is tested
26529
+ * without one.
26530
+ *
26531
+ * Copyright (c) 2026, ArtGins.
26532
+ * All Rights Reserved.
26533
+ ****************************************************************************/
26534
+ /************************************************************
26535
+ * A value shown as text, whatever characters it holds
26536
+ ************************************************************/
26537
+ function cell_text(value, doc = globalThis.document) {
26538
+ if (value === null || value === void 0) return "";
26539
+ return doc.createTextNode(String(value));
26540
+ }
26541
+ /************************************************************
26542
+ * The hook cell, as a createElement2 spec: the row id is an
26543
+ * attribute VALUE, never text spliced into markup (an id holding
26544
+ * a `"` cut the attribute and lost the rest of the id)
26545
+ ************************************************************/
26546
+ function hook_cell_spec(row_id, col_id, items, title, title_key) {
26547
+ return [
26548
+ "a",
26549
+ {
26550
+ class: "HOOK_CELL hook_cell",
26551
+ title,
26552
+ "data-i18n-title": title_key,
26553
+ "aria-label": title,
26554
+ "data-i18n-aria-label": title_key,
26555
+ "data-row_id": String(row_id),
26556
+ "data-col_id": String(col_id)
26557
+ },
26558
+ [["span", { class: "icon yi-eye" }], [
26559
+ "u",
26560
+ { class: "HOOK_CELL_COUNT" },
26561
+ `[ ${items} ]`
26562
+ ]]
26563
+ ];
26564
+ }
26565
+ //#endregion
26483
26566
  //#region src/c_yui_treedb_topic_with_form.js
26484
26567
  /***********************************************************************
26485
26568
  * c_yui_treedb_topic_with_form.js
@@ -27906,18 +27989,8 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27906
27989
  break;
27907
27990
  case "hook":
27908
27991
  let items = (0, _yuneta_gobj_js.treedb_hook_data_size)(value);
27909
- if (items > 0) {
27910
- let hook_title = String((0, i18next.t)("show linked records")).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27911
- value = [
27912
- "<a class=\"hook_cell\" ",
27913
- `title="${hook_title}" aria-label="${hook_title}" `,
27914
- `data-row_id="${row.id}" `,
27915
- `data-col_id="${col.id}" > `,
27916
- "<span style=\"\" class=\"icon yi-eye\"></span>",
27917
- `<span>[&nbsp;<u>${items}</u>&nbsp;]</span>`,
27918
- "</a>"
27919
- ].join("");
27920
- } else value = "";
27992
+ if (items > 0) value = (0, _yuneta_gobj_js.createElement2)(hook_cell_spec(row.id, col.id, items, (0, i18next.t)("show linked records"), "show linked records"));
27993
+ else value = "";
27921
27994
  break;
27922
27995
  case "file":
27923
27996
  case "fkey":
@@ -27943,7 +28016,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27943
28016
  value = /* @__PURE__ */ new Date(value * 1e3);
27944
28017
  const rawLocale = navigator.language;
27945
28018
  const userLocale = typeof rawLocale === "string" && rawLocale && rawLocale !== "undefined" ? rawLocale : void 0;
27946
- value = "<span class=\"is-size-7\">" + new Intl.DateTimeFormat(userLocale, {
28019
+ const formatter = new Intl.DateTimeFormat(userLocale, {
27947
28020
  day: "numeric",
27948
28021
  month: "short",
27949
28022
  year: "numeric",
@@ -27951,7 +28024,12 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27951
28024
  minute: "2-digit",
27952
28025
  second: "2-digit",
27953
28026
  hour12: true
27954
- }).format(value) + "</span>";
28027
+ });
28028
+ value = (0, _yuneta_gobj_js.createElement2)([
28029
+ "span",
28030
+ { class: "is-size-7" },
28031
+ formatter.format(value)
28032
+ ]);
27955
28033
  }
27956
28034
  break;
27957
28035
  case "color": break;
@@ -27963,6 +28041,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27963
28041
  (0, _yuneta_gobj_js.log_error)(`transform__treedb_value_2_table_value() unexpected object value for field '${field}' (type='${field_desc.type}', real_type='${field_desc.real_type}'): ${JSON.stringify(value)}, topic ${priv.topic_name}`);
27964
28042
  value = JSON.stringify(value);
27965
28043
  }
28044
+ if (typeof value === "string" && value !== "") value = cell_text(value);
27966
28045
  return value;
27967
28046
  }
27968
28047
  /************************************************************
@@ -7305,6 +7305,39 @@ function json_diff_rows(doc_a, doc_b) {
7305
7305
  return rows;
7306
7306
  }
7307
7307
  //#endregion
7308
+ //#region src/form_time_value.js
7309
+ /****************************************************************************
7310
+ * form_time_value.js
7311
+ *
7312
+ * A treedb `time` / `now` column is an epoch in SECONDS, and a form
7313
+ * shows it in a `datetime-local` input. These two convert between
7314
+ * them, and they must be each other's inverse: a writable time
7315
+ * column travels back on EVERY save of the record.
7316
+ *
7317
+ * Copyright (c) 2026, ArtGins.
7318
+ * All Rights Reserved.
7319
+ ****************************************************************************/
7320
+ /************************************************************
7321
+ * A Date -> the value of a `datetime-local` input, in local time,
7322
+ * WITH seconds: the input is built with `step: 1`, which is what
7323
+ * lets it hold them. Written as "YYYY-MM-DDTHH:mm" it lost them on
7324
+ * every save of any field (M26 of the 2026-09-21 review).
7325
+ ************************************************************/
7326
+ function date_to_datetime_local(value) {
7327
+ if (!(value instanceof Date) || isNaN(value.getTime())) return "";
7328
+ let pad = (n) => String(n).padStart(2, "0");
7329
+ return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
7330
+ }
7331
+ /************************************************************
7332
+ * The value of a `datetime-local` input -> epoch in seconds
7333
+ ************************************************************/
7334
+ function datetime_local_to_epoch(value) {
7335
+ if (!value) return null;
7336
+ const date = new Date(value);
7337
+ if (isNaN(date.getTime())) return null;
7338
+ return Math.floor(date.getTime() / 1e3);
7339
+ }
7340
+ //#endregion
7308
7341
  //#region src/c_yui_form.js
7309
7342
  /***********************************************************************
7310
7343
  * c_yui_form.js
@@ -8317,6 +8350,7 @@ function create_form_field(gobj, $form, prefix, field_desc, { tag, inputType, na
8317
8350
  type: inputType,
8318
8351
  placeholder
8319
8352
  };
8353
+ if (inputType === "datetime-local") attrs.step = 1;
8320
8354
  Object.assign(attrs, extras);
8321
8355
  if (readonly) attrs.readonly = "";
8322
8356
  else if (required) attrs.required = "";
@@ -9017,8 +9051,7 @@ function get_form_values(gobj, $form) {
9017
9051
  case "input":
9018
9052
  switch (input_type) {
9019
9053
  case "datetime-local":
9020
- const date = new Date($input.value);
9021
- value = Math.floor(date.getTime() / 1e3);
9054
+ value = datetime_local_to_epoch($input.value);
9022
9055
  break;
9023
9056
  default: value = getInputValue($input);
9024
9057
  }
@@ -9181,10 +9214,7 @@ function set_form_values(gobj, template, $form, record) {
9181
9214
  case "input":
9182
9215
  switch (input_type) {
9183
9216
  case "datetime-local":
9184
- if (value instanceof Date && !isNaN(value.getTime())) {
9185
- let pad = (n) => String(n).padStart(2, "0");
9186
- $input.value = `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
9187
- } else $input.value = "";
9217
+ $input.value = date_to_datetime_local(value);
9188
9218
  break;
9189
9219
  case "color":
9190
9220
  $input.value = convertColor(value);
@@ -25235,6 +25265,7 @@ function ac_create_record(gobj, event, kw, src) {
25235
25265
  if (treedb_update_node$1(gobj, treedb_name, topic_name, record, {
25236
25266
  list_dict: true,
25237
25267
  create: true,
25268
+ create_only: true,
25238
25269
  autolink: true
25239
25270
  }, form_write) < 0) {
25240
25271
  answer_form_write(gobj, src, form_write, false);
@@ -26475,6 +26506,58 @@ function delete_impact(desc, records) {
26475
26506
  return out;
26476
26507
  }
26477
26508
  //#endregion
26509
+ //#region src/table_cell_text.js
26510
+ /****************************************************************************
26511
+ * table_cell_text.js
26512
+ *
26513
+ * What a treedb table cell hands Tabulator.
26514
+ *
26515
+ * Tabulator assigns a formatter's STRING result with `innerHTML`
26516
+ * (only its own `plaintext` formatter escapes), so a record's text
26517
+ * returned as a string is parsed as markup: `a<b and c>d` showed
26518
+ * as `ad`, and a field holding `<img src=x onerror=...>` ran it in
26519
+ * the operator's browser (M31 of the 2026-09-21 review). Record
26520
+ * data goes in as a TEXT NODE, and a cell built around it as DOM,
26521
+ * never as an HTML string with the data interpolated.
26522
+ *
26523
+ * Pure but for the `document` it is handed, so it is tested
26524
+ * without one.
26525
+ *
26526
+ * Copyright (c) 2026, ArtGins.
26527
+ * All Rights Reserved.
26528
+ ****************************************************************************/
26529
+ /************************************************************
26530
+ * A value shown as text, whatever characters it holds
26531
+ ************************************************************/
26532
+ function cell_text(value, doc = globalThis.document) {
26533
+ if (value === null || value === void 0) return "";
26534
+ return doc.createTextNode(String(value));
26535
+ }
26536
+ /************************************************************
26537
+ * The hook cell, as a createElement2 spec: the row id is an
26538
+ * attribute VALUE, never text spliced into markup (an id holding
26539
+ * a `"` cut the attribute and lost the rest of the id)
26540
+ ************************************************************/
26541
+ function hook_cell_spec(row_id, col_id, items, title, title_key) {
26542
+ return [
26543
+ "a",
26544
+ {
26545
+ class: "HOOK_CELL hook_cell",
26546
+ title,
26547
+ "data-i18n-title": title_key,
26548
+ "aria-label": title,
26549
+ "data-i18n-aria-label": title_key,
26550
+ "data-row_id": String(row_id),
26551
+ "data-col_id": String(col_id)
26552
+ },
26553
+ [["span", { class: "icon yi-eye" }], [
26554
+ "u",
26555
+ { class: "HOOK_CELL_COUNT" },
26556
+ `[ ${items} ]`
26557
+ ]]
26558
+ ];
26559
+ }
26560
+ //#endregion
26478
26561
  //#region src/c_yui_treedb_topic_with_form.js
26479
26562
  /***********************************************************************
26480
26563
  * c_yui_treedb_topic_with_form.js
@@ -27901,18 +27984,8 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27901
27984
  break;
27902
27985
  case "hook":
27903
27986
  let items = treedb_hook_data_size(value);
27904
- if (items > 0) {
27905
- let hook_title = String(t("show linked records")).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
27906
- value = [
27907
- "<a class=\"hook_cell\" ",
27908
- `title="${hook_title}" aria-label="${hook_title}" `,
27909
- `data-row_id="${row.id}" `,
27910
- `data-col_id="${col.id}" > `,
27911
- "<span style=\"\" class=\"icon yi-eye\"></span>",
27912
- `<span>[&nbsp;<u>${items}</u>&nbsp;]</span>`,
27913
- "</a>"
27914
- ].join("");
27915
- } else value = "";
27987
+ if (items > 0) value = createElement2(hook_cell_spec(row.id, col.id, items, t("show linked records"), "show linked records"));
27988
+ else value = "";
27916
27989
  break;
27917
27990
  case "file":
27918
27991
  case "fkey":
@@ -27938,7 +28011,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27938
28011
  value = /* @__PURE__ */ new Date(value * 1e3);
27939
28012
  const rawLocale = navigator.language;
27940
28013
  const userLocale = typeof rawLocale === "string" && rawLocale && rawLocale !== "undefined" ? rawLocale : void 0;
27941
- value = "<span class=\"is-size-7\">" + new Intl.DateTimeFormat(userLocale, {
28014
+ const formatter = new Intl.DateTimeFormat(userLocale, {
27942
28015
  day: "numeric",
27943
28016
  month: "short",
27944
28017
  year: "numeric",
@@ -27946,7 +28019,12 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27946
28019
  minute: "2-digit",
27947
28020
  second: "2-digit",
27948
28021
  hour12: true
27949
- }).format(value) + "</span>";
28022
+ });
28023
+ value = createElement2([
28024
+ "span",
28025
+ { class: "is-size-7" },
28026
+ formatter.format(value)
28027
+ ]);
27950
28028
  }
27951
28029
  break;
27952
28030
  case "color": break;
@@ -27958,6 +28036,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field) {
27958
28036
  log_error(`transform__treedb_value_2_table_value() unexpected object value for field '${field}' (type='${field_desc.type}', real_type='${field_desc.real_type}'): ${JSON.stringify(value)}, topic ${priv.topic_name}`);
27959
28037
  value = JSON.stringify(value);
27960
28038
  }
28039
+ if (typeof value === "string" && value !== "") value = cell_text(value);
27961
28040
  return value;
27962
28041
  }
27963
28042
  /************************************************************
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.23.193",
3
+ "version": "7.23.195",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
package/src/c_yui_form.js CHANGED
@@ -83,6 +83,7 @@ import "tabulator-tables/dist/css/tabulator_bulma.css";
83
83
 
84
84
  import "./tabulator.css";
85
85
  import {is_time_field} from "./json_view_helpers.js";
86
+ import {date_to_datetime_local, datetime_local_to_epoch} from "./form_time_value.js";
86
87
  import { TabulatorFull as Tabulator } from "tabulator-tables"; // Import Full Tabulator JS
87
88
  // import { Tabulator } from "tabulator-tables"; // Import light Tabulator JS
88
89
 
@@ -1247,6 +1248,12 @@ function create_form_field(
1247
1248
  type: inputType,
1248
1249
  placeholder: placeholder
1249
1250
  };
1251
+ if(inputType === 'datetime-local') {
1252
+ /* A time column is an epoch in SECONDS: without `step: 1`
1253
+ * the input holds minutes, and a save wrote the seconds
1254
+ * away (see form_time_value.js). */
1255
+ attrs.step = 1;
1256
+ }
1250
1257
  Object.assign(attrs, extras);
1251
1258
 
1252
1259
  if(readonly) {
@@ -2321,10 +2328,7 @@ function get_form_values(gobj, $form)
2321
2328
  case "input":
2322
2329
  switch(input_type) {
2323
2330
  case "datetime-local":
2324
- // Create a new Date object from the datetime-local $input value
2325
- const date = new Date($input.value);
2326
- // Convert the Date object to epoch time (in seconds)
2327
- value = Math.floor(date.getTime() / 1000);
2331
+ value = datetime_local_to_epoch($input.value);
2328
2332
  break;
2329
2333
  default:
2330
2334
  value = getInputValue($input);
@@ -2588,18 +2592,8 @@ function set_form_values(gobj, template, $form, record)
2588
2592
  case "input":
2589
2593
  switch(input_type) {
2590
2594
  case "datetime-local":
2591
- /* value is a Date (see treedb_value_2_form_value);
2592
- * a datetime-local input wants local
2593
- * "YYYY-MM-DDTHH:mm" (no seconds). */
2594
- if(value instanceof Date && !isNaN(value.getTime())) {
2595
- let pad = (n) => String(n).padStart(2, "0");
2596
- $input.value =
2597
- `${value.getFullYear()}-${pad(value.getMonth() + 1)}` +
2598
- `-${pad(value.getDate())}T` +
2599
- `${pad(value.getHours())}:${pad(value.getMinutes())}`;
2600
- } else {
2601
- $input.value = "";
2602
- }
2595
+ /* value is a Date (see treedb_value_2_form_value) */
2596
+ $input.value = date_to_datetime_local(value);
2603
2597
  break;
2604
2598
  case "color":
2605
2599
  $input.value = convertColor(value);
@@ -111,6 +111,7 @@ import {delete_impact} from "./delete_impact.js";
111
111
 
112
112
  import "./c_yui_treedb_topic_with_form.css";
113
113
  import "./tabulator.css";
114
+ import {cell_text, hook_cell_spec} from "./table_cell_text.js";
114
115
 
115
116
  import { TabulatorFull as Tabulator } from "tabulator-tables";
116
117
 
@@ -2147,20 +2148,12 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field)
2147
2148
  let items = treedb_hook_data_size(value);
2148
2149
 
2149
2150
  if(items > 0) {
2150
- /* An HTML STRING, so the name is escaped by hand; a language
2151
- * change re-runs setColumns(), which re-runs this. */
2152
- let hook_title = String(t("show linked records"))
2153
- .replace(/&/g, "&amp;").replace(/"/g, "&quot;")
2154
- .replace(/</g, "&lt;").replace(/>/g, "&gt;");
2155
- value = [
2156
- '<a class="hook_cell" ',
2157
- `title="${hook_title}" aria-label="${hook_title}" `,
2158
- `data-row_id="${row.id}" `,
2159
- `data-col_id="${col.id}" > `,
2160
- '<span style="" class="icon yi-eye"></span>',
2161
- `<span>[&nbsp;<u>${items}</u>&nbsp;]</span>`,
2162
- '</a>'
2163
- ].join('');
2151
+ /* DOM, not an HTML string: the row id is record data (M31
2152
+ * of the 2026-09-21 review). A language change re-runs
2153
+ * setColumns(), which re-runs this. */
2154
+ value = createElement2(hook_cell_spec(
2155
+ row.id, col.id, items, t("show linked records"), "show linked records"
2156
+ ));
2164
2157
  } else {
2165
2158
  value = "";
2166
2159
  }
@@ -2217,7 +2210,7 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field)
2217
2210
  };
2218
2211
 
2219
2212
  const formatter = new Intl.DateTimeFormat(userLocale, opts);
2220
- value = '<span class="is-size-7">' + formatter.format(value) + '</span>';
2213
+ value = createElement2(['span', {class: 'is-size-7'}, formatter.format(value)]);
2221
2214
  break;
2222
2215
  }
2223
2216
  break;
@@ -2249,6 +2242,15 @@ function transform__treedb_value_2_table_value(gobj, col, value, row, field)
2249
2242
  value = JSON.stringify(value);
2250
2243
  }
2251
2244
 
2245
+ /*
2246
+ * A string is record data, and Tabulator would parse it as markup
2247
+ * (innerHTML): `a<b` lost its text, a field holding an `<img onerror>`
2248
+ * ran it (M31 of the 2026-09-21 review). Shown as a text node.
2249
+ */
2250
+ if(typeof value === "string" && value !== "") {
2251
+ value = cell_text(value);
2252
+ }
2253
+
2252
2254
  return value;
2253
2255
  }
2254
2256
 
@@ -2523,9 +2523,15 @@ function ac_create_record(gobj, event, kw, src)
2523
2523
  let topic_name = gobj_read_attr(src, "topic_name");
2524
2524
  let record = kw.record;
2525
2525
 
2526
+ /* `create_only`: +New makes a NEW record. With `create` alone a taken
2527
+ * id was an update -- the record overwritten and, through autolink
2528
+ * with the selects empty, unlinked, answered "Node update!" (M28 of
2529
+ * the 2026-09-21 review). A backend older than the option ignores it
2530
+ * and keeps the old upsert. */
2526
2531
  let options = {
2527
2532
  list_dict: true,
2528
2533
  create: true,
2534
+ create_only: true,
2529
2535
  autolink: true
2530
2536
  };
2531
2537
 
@@ -0,0 +1,46 @@
1
+ /****************************************************************************
2
+ * form_time_value.js
3
+ *
4
+ * A treedb `time` / `now` column is an epoch in SECONDS, and a form
5
+ * shows it in a `datetime-local` input. These two convert between
6
+ * them, and they must be each other's inverse: a writable time
7
+ * column travels back on EVERY save of the record.
8
+ *
9
+ * Copyright (c) 2026, ArtGins.
10
+ * All Rights Reserved.
11
+ ****************************************************************************/
12
+
13
+ /************************************************************
14
+ * A Date -> the value of a `datetime-local` input, in local time,
15
+ * WITH seconds: the input is built with `step: 1`, which is what
16
+ * lets it hold them. Written as "YYYY-MM-DDTHH:mm" it lost them on
17
+ * every save of any field (M26 of the 2026-09-21 review).
18
+ ************************************************************/
19
+ function date_to_datetime_local(value)
20
+ {
21
+ if(!(value instanceof Date) || isNaN(value.getTime())) {
22
+ return "";
23
+ }
24
+ let pad = (n) => String(n).padStart(2, "0");
25
+ return `${value.getFullYear()}-${pad(value.getMonth() + 1)}` +
26
+ `-${pad(value.getDate())}T` +
27
+ `${pad(value.getHours())}:${pad(value.getMinutes())}` +
28
+ `:${pad(value.getSeconds())}`;
29
+ }
30
+
31
+ /************************************************************
32
+ * The value of a `datetime-local` input -> epoch in seconds
33
+ ************************************************************/
34
+ function datetime_local_to_epoch(value)
35
+ {
36
+ if(!value) {
37
+ return null;
38
+ }
39
+ const date = new Date(value);
40
+ if(isNaN(date.getTime())) {
41
+ return null;
42
+ }
43
+ return Math.floor(date.getTime() / 1000);
44
+ }
45
+
46
+ export {date_to_datetime_local, datetime_local_to_epoch};
@@ -0,0 +1,23 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {date_to_datetime_local, datetime_local_to_epoch} from "./form_time_value.js";
3
+
4
+ describe("form_time_value", () => {
5
+
6
+ it("an epoch with seconds survives a round trip through the input", () => {
7
+ /* M26 of the 2026-09-21 review: the input was written without
8
+ * seconds, so every save of ANY field moved a writable time
9
+ * column back up to 59 s. */
10
+ const epoch = 1790000037; // :37 seconds
11
+ const shown = date_to_datetime_local(new Date(epoch * 1000));
12
+ expect(datetime_local_to_epoch(shown)).toBe(epoch);
13
+ });
14
+
15
+ it("an empty input is null, not NaN", () => {
16
+ expect(datetime_local_to_epoch("")).toBe(null);
17
+ });
18
+
19
+ it("no date shows as an empty input", () => {
20
+ expect(date_to_datetime_local(null)).toBe("");
21
+ expect(date_to_datetime_local(new Date(NaN))).toBe("");
22
+ });
23
+ });
@@ -0,0 +1,53 @@
1
+ /****************************************************************************
2
+ * table_cell_text.js
3
+ *
4
+ * What a treedb table cell hands Tabulator.
5
+ *
6
+ * Tabulator assigns a formatter's STRING result with `innerHTML`
7
+ * (only its own `plaintext` formatter escapes), so a record's text
8
+ * returned as a string is parsed as markup: `a<b and c>d` showed
9
+ * as `ad`, and a field holding `<img src=x onerror=...>` ran it in
10
+ * the operator's browser (M31 of the 2026-09-21 review). Record
11
+ * data goes in as a TEXT NODE, and a cell built around it as DOM,
12
+ * never as an HTML string with the data interpolated.
13
+ *
14
+ * Pure but for the `document` it is handed, so it is tested
15
+ * without one.
16
+ *
17
+ * Copyright (c) 2026, ArtGins.
18
+ * All Rights Reserved.
19
+ ****************************************************************************/
20
+
21
+ /************************************************************
22
+ * A value shown as text, whatever characters it holds
23
+ ************************************************************/
24
+ function cell_text(value, doc = globalThis.document)
25
+ {
26
+ if(value === null || value === undefined) {
27
+ return "";
28
+ }
29
+ return doc.createTextNode(String(value));
30
+ }
31
+
32
+ /************************************************************
33
+ * The hook cell, as a createElement2 spec: the row id is an
34
+ * attribute VALUE, never text spliced into markup (an id holding
35
+ * a `"` cut the attribute and lost the rest of the id)
36
+ ************************************************************/
37
+ function hook_cell_spec(row_id, col_id, items, title, title_key)
38
+ {
39
+ return ['a', {
40
+ class: 'HOOK_CELL hook_cell',
41
+ title: title,
42
+ 'data-i18n-title': title_key,
43
+ 'aria-label': title,
44
+ 'data-i18n-aria-label': title_key,
45
+ 'data-row_id': String(row_id),
46
+ 'data-col_id': String(col_id)
47
+ }, [
48
+ ['span', {class: 'icon yi-eye'}],
49
+ ['u', {class: 'HOOK_CELL_COUNT'}, `[ ${items} ]`]
50
+ ]];
51
+ }
52
+
53
+ export {cell_text, hook_cell_spec};
@@ -0,0 +1,39 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {cell_text, hook_cell_spec} from "./table_cell_text.js";
3
+
4
+ /* A document double: the library has no DOM test (see yui_clipboard.test.js) */
5
+ const doc = {
6
+ createTextNode: (text) => ({nodeType: 3, textContent: text})
7
+ };
8
+
9
+ describe("table_cell_text", () => {
10
+
11
+ it("record text is a text node, markup included", () => {
12
+ /* M31: Tabulator puts a string through innerHTML */
13
+ const node = cell_text("limit a<b and c>d", doc);
14
+ expect(node.nodeType).toBe(3);
15
+ expect(node.textContent).toBe("limit a<b and c>d");
16
+ expect(cell_text('<img src=x onerror=alert(1)>', doc).textContent)
17
+ .toBe('<img src=x onerror=alert(1)>');
18
+ });
19
+
20
+ it("numbers and booleans are shown as text too, nothing is empty", () => {
21
+ expect(cell_text(0, doc).textContent).toBe("0");
22
+ expect(cell_text(false, doc).textContent).toBe("false");
23
+ expect(cell_text(null, doc)).toBe("");
24
+ expect(cell_text(undefined, doc)).toBe("");
25
+ });
26
+
27
+ it("the hook cell carries the row id whole, quote included", () => {
28
+ const spec = hook_cell_spec(
29
+ 'es"madrid', "users", 3, "ver registros enlazados", "show linked records"
30
+ );
31
+ expect(spec[0]).toBe("a");
32
+ expect(spec[1]["data-row_id"]).toBe('es"madrid');
33
+ expect(spec[1]["data-col_id"]).toBe("users");
34
+ expect(spec[1].title).toBe("ver registros enlazados");
35
+ expect(spec[1]["aria-label"]).toBe("ver registros enlazados");
36
+ expect(spec[1]["data-i18n-title"]).toBe("show linked records");
37
+ expect(spec[1]["data-i18n-aria-label"]).toBe("show linked records");
38
+ });
39
+ });