@mulmoclaude/collection-plugin 0.7.5 → 0.8.0

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/dist/vue.js CHANGED
@@ -1,4 +1,4 @@
1
- import { MINUTES_PER_DAY, TOOL_DEFINITION, actionVisible, assignLanes, boolSortValue, bucketRecords, buildMonthGrid, buildUpdatedRecord, coerceInlineValue, dateOf, dateSortValue, daySlice, defangForPrompt, deriveAll, draftToRecord, embedTargetId, emptyRow, enumSortValue, errorMessage, executePresentCollection, fieldVisible, firstMissingRequiredField, isSortableField, itemIdOf, itemLabelOf, labelFieldFor, nextSortDirection, numericSortValue, resolveEnumColor, rowFromItem, shortHexId, sortItems, stringSortValue, ymdKey } from "@mulmoclaude/core/collection";
1
+ import { MINUTES_PER_DAY, TOOL_DEFINITION, actionVisible, assignLanes, backlinkRows, boolSortValue, bucketRecords, buildMonthGrid, buildUpdatedRecord, coerceInlineValue, dateOf, dateSortValue, daySlice, defangForPrompt, deriveAll, draftToRecord, embedTargetId, emptyRow, enumSortValue, errorMessage, executePresentCollection, fieldVisible, firstMissingRequiredField, isSortableField, itemIdOf, itemLabelOf, labelFieldFor, nextSortDirection, numericSortValue, resolveEnumColor, rowFromItem, shortHexId, sortItems, stringSortValue, ymdKey } from "@mulmoclaude/core/collection";
2
2
  import { Fragment, Teleport, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, effectScope, mergeModels, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onUnmounted, openBlock, ref, renderList, renderSlot, resolveDynamicComponent, toDisplayString, unref, useModel, vModelCheckbox, vModelDynamic, vModelSelect, vModelText, watch, watchEffect, withCtx, withDirectives, withKeys, withModifiers } from "vue";
3
3
  import { createI18n } from "vue-i18n";
4
4
  import draggable from "vuedraggable";
@@ -1210,18 +1210,18 @@ var CollectionRecordModal_default = /* @__PURE__ */ defineComponent({
1210
1210
  });
1211
1211
  //#endregion
1212
1212
  //#region src/vue/components/CollectionCalendarView.vue?vue&type=script&setup=true&lang.ts
1213
- var _hoisted_1$14 = {
1213
+ var _hoisted_1$15 = {
1214
1214
  class: "flex flex-col gap-3",
1215
1215
  "data-testid": "collection-calendar"
1216
1216
  };
1217
- var _hoisted_2$13 = { class: "flex items-center gap-2" };
1218
- var _hoisted_3$13 = ["aria-label"];
1219
- var _hoisted_4$13 = ["aria-label"];
1220
- var _hoisted_5$12 = {
1217
+ var _hoisted_2$14 = { class: "flex items-center gap-2" };
1218
+ var _hoisted_3$14 = ["aria-label"];
1219
+ var _hoisted_4$14 = ["aria-label"];
1220
+ var _hoisted_5$13 = {
1221
1221
  class: "text-sm font-bold text-slate-800 flex-1",
1222
1222
  "data-testid": "collection-calendar-month"
1223
1223
  };
1224
- var _hoisted_6$11 = { class: "grid grid-cols-7 gap-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider select-none" };
1224
+ var _hoisted_6$12 = { class: "grid grid-cols-7 gap-1 text-[10px] font-bold text-slate-400 uppercase tracking-wider select-none" };
1225
1225
  var _hoisted_7$10 = { class: "grid grid-cols-7 gap-1" };
1226
1226
  var _hoisted_8$10 = [
1227
1227
  "aria-label",
@@ -1351,23 +1351,23 @@ var CollectionCalendarView_default = /* @__PURE__ */ defineComponent({
1351
1351
  viewMonth.value = now.getMonth() + 1;
1352
1352
  }
1353
1353
  return (_ctx, _cache) => {
1354
- return openBlock(), createElementBlock("div", _hoisted_1$14, [
1355
- createElementVNode("div", _hoisted_2$13, [
1354
+ return openBlock(), createElementBlock("div", _hoisted_1$15, [
1355
+ createElementVNode("div", _hoisted_2$14, [
1356
1356
  createElementVNode("button", {
1357
1357
  type: "button",
1358
1358
  class: "h-8 w-8 flex items-center justify-center rounded text-slate-500 hover:bg-slate-100 transition-colors",
1359
1359
  "aria-label": unref(t)("collectionsView.calendarPrevMonth"),
1360
1360
  "data-testid": "collection-calendar-prev",
1361
1361
  onClick: _cache[0] || (_cache[0] = ($event) => stepMonth(-1))
1362
- }, [..._cache[2] || (_cache[2] = [createElementVNode("span", { class: "material-icons text-lg" }, "chevron_left", -1)])], 8, _hoisted_3$13),
1362
+ }, [..._cache[2] || (_cache[2] = [createElementVNode("span", { class: "material-icons text-lg" }, "chevron_left", -1)])], 8, _hoisted_3$14),
1363
1363
  createElementVNode("button", {
1364
1364
  type: "button",
1365
1365
  class: "h-8 w-8 flex items-center justify-center rounded text-slate-500 hover:bg-slate-100 transition-colors",
1366
1366
  "aria-label": unref(t)("collectionsView.calendarNextMonth"),
1367
1367
  "data-testid": "collection-calendar-next",
1368
1368
  onClick: _cache[1] || (_cache[1] = ($event) => stepMonth(1))
1369
- }, [..._cache[3] || (_cache[3] = [createElementVNode("span", { class: "material-icons text-lg" }, "chevron_right", -1)])], 8, _hoisted_4$13),
1370
- createElementVNode("h3", _hoisted_5$12, toDisplayString(monthLabel.value), 1),
1369
+ }, [..._cache[3] || (_cache[3] = [createElementVNode("span", { class: "material-icons text-lg" }, "chevron_right", -1)])], 8, _hoisted_4$14),
1370
+ createElementVNode("h3", _hoisted_5$13, toDisplayString(monthLabel.value), 1),
1371
1371
  createElementVNode("button", {
1372
1372
  type: "button",
1373
1373
  class: "h-8 px-2.5 flex items-center gap-1 rounded border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 text-xs font-bold transition-colors",
@@ -1375,7 +1375,7 @@ var CollectionCalendarView_default = /* @__PURE__ */ defineComponent({
1375
1375
  onClick: goToday
1376
1376
  }, toDisplayString(unref(t)("collectionsView.calendarToday")), 1)
1377
1377
  ]),
1378
- createElementVNode("div", _hoisted_6$11, [(openBlock(true), createElementBlock(Fragment, null, renderList(weekdayLabels.value, (label, idx) => {
1378
+ createElementVNode("div", _hoisted_6$12, [(openBlock(true), createElementBlock(Fragment, null, renderList(weekdayLabels.value, (label, idx) => {
1379
1379
  return openBlock(), createElementBlock("div", {
1380
1380
  key: idx,
1381
1381
  class: "px-1 py-1 text-center"
@@ -1416,18 +1416,18 @@ var CollectionCalendarView_default = /* @__PURE__ */ defineComponent({
1416
1416
  });
1417
1417
  //#endregion
1418
1418
  //#region src/vue/components/CollectionDayView.vue?vue&type=script&setup=true&lang.ts
1419
- var _hoisted_1$13 = { class: "flex items-center gap-2 border-b border-slate-200 px-4 py-3" };
1420
- var _hoisted_2$12 = {
1419
+ var _hoisted_1$14 = { class: "flex items-center gap-2 border-b border-slate-200 px-4 py-3" };
1420
+ var _hoisted_2$13 = {
1421
1421
  class: "flex-1 text-sm font-bold text-slate-800",
1422
1422
  "data-testid": "collection-day-view-title"
1423
1423
  };
1424
- var _hoisted_3$12 = ["aria-label"];
1425
- var _hoisted_4$12 = ["aria-label"];
1426
- var _hoisted_5$11 = {
1424
+ var _hoisted_3$13 = ["aria-label"];
1425
+ var _hoisted_4$13 = ["aria-label"];
1426
+ var _hoisted_5$12 = {
1427
1427
  key: 0,
1428
1428
  class: "px-4 py-10 text-center text-sm text-slate-400"
1429
1429
  };
1430
- var _hoisted_6$10 = { class: "absolute -top-2 left-0 w-10 pr-1 text-right text-[10px] tabular-nums text-slate-400" };
1430
+ var _hoisted_6$11 = { class: "absolute -top-2 left-0 w-10 pr-1 text-right text-[10px] tabular-nums text-slate-400" };
1431
1431
  var _hoisted_7$9 = {
1432
1432
  class: "absolute inset-y-0 right-0",
1433
1433
  style: { "left": "2.75rem" }
@@ -1513,6 +1513,7 @@ var CollectionDayView_default = /* @__PURE__ */ defineComponent({
1513
1513
  "datetime",
1514
1514
  "table",
1515
1515
  "embed",
1516
+ "backlinks",
1516
1517
  "image",
1517
1518
  "markdown"
1518
1519
  ]);
@@ -1608,8 +1609,8 @@ var CollectionDayView_default = /* @__PURE__ */ defineComponent({
1608
1609
  role: "dialog",
1609
1610
  "aria-modal": "true"
1610
1611
  }, [createElementVNode("div", { class: normalizeClass(["flex min-h-0 flex-col", __props.showDetail ? "w-80 shrink-0 border-r border-slate-200" : "w-full"]) }, [
1611
- createElementVNode("div", _hoisted_1$13, [
1612
- createElementVNode("h3", _hoisted_2$12, toDisplayString(dayLabel.value), 1),
1612
+ createElementVNode("div", _hoisted_1$14, [
1613
+ createElementVNode("h3", _hoisted_2$13, toDisplayString(dayLabel.value), 1),
1613
1614
  __props.canCreate ? (openBlock(), createElementBlock("button", {
1614
1615
  key: 0,
1615
1616
  type: "button",
@@ -1617,16 +1618,16 @@ var CollectionDayView_default = /* @__PURE__ */ defineComponent({
1617
1618
  "aria-label": unref(t)("collectionsView.calendarCreateOn", { date: dayKey.value }),
1618
1619
  "data-testid": "collection-day-view-create",
1619
1620
  onClick: onCreate
1620
- }, [..._cache[3] || (_cache[3] = [createElementVNode("span", { class: "material-icons text-lg" }, "add", -1)])], 8, _hoisted_3$12)) : createCommentVNode("", true),
1621
+ }, [..._cache[3] || (_cache[3] = [createElementVNode("span", { class: "material-icons text-lg" }, "add", -1)])], 8, _hoisted_3$13)) : createCommentVNode("", true),
1621
1622
  createElementVNode("button", {
1622
1623
  type: "button",
1623
1624
  class: "h-8 w-8 flex items-center justify-center rounded text-slate-500 hover:bg-slate-100 transition-colors",
1624
1625
  "aria-label": unref(t)("collectionsView.dayViewClose"),
1625
1626
  "data-testid": "collection-day-view-close",
1626
1627
  onClick: _cache[0] || (_cache[0] = ($event) => emit("close"))
1627
- }, [..._cache[4] || (_cache[4] = [createElementVNode("span", { class: "material-icons text-lg" }, "close", -1)])], 8, _hoisted_4$12)
1628
+ }, [..._cache[4] || (_cache[4] = [createElementVNode("span", { class: "material-icons text-lg" }, "close", -1)])], 8, _hoisted_4$13)
1628
1629
  ]),
1629
- timedEntries.value.length === 0 && allDayEntries.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_5$11, toDisplayString(unref(t)("collectionsView.dayViewEmpty")), 1)) : (openBlock(), createElementBlock("div", {
1630
+ timedEntries.value.length === 0 && allDayEntries.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_5$12, toDisplayString(unref(t)("collectionsView.dayViewEmpty")), 1)) : (openBlock(), createElementBlock("div", {
1630
1631
  key: 1,
1631
1632
  ref_key: "scrollEl",
1632
1633
  ref: scrollEl,
@@ -1640,7 +1641,7 @@ var CollectionDayView_default = /* @__PURE__ */ defineComponent({
1640
1641
  key: hour,
1641
1642
  class: "absolute left-0 right-0 border-t border-slate-100",
1642
1643
  style: normalizeStyle({ top: `${(hour - 1) * HOUR_PX}px` })
1643
- }, [createElementVNode("span", _hoisted_6$10, toDisplayString(hourLabel(hour - 1)), 1)], 4);
1644
+ }, [createElementVNode("span", _hoisted_6$11, toDisplayString(hourLabel(hour - 1)), 1)], 4);
1644
1645
  }), 64)), createElementVNode("div", _hoisted_7$9, [(openBlock(true), createElementBlock(Fragment, null, renderList(timedEntries.value, (entry) => {
1645
1646
  return openBlock(), createElementBlock("button", {
1646
1647
  key: entry.id,
@@ -1675,15 +1676,15 @@ var CollectionDayView_default = /* @__PURE__ */ defineComponent({
1675
1676
  });
1676
1677
  //#endregion
1677
1678
  //#region src/vue/components/CollectionKanbanView.vue?vue&type=script&setup=true&lang.ts
1678
- var _hoisted_1$12 = {
1679
+ var _hoisted_1$13 = {
1679
1680
  class: "h-full overflow-x-auto overflow-y-hidden",
1680
1681
  "data-testid": "collection-kanban"
1681
1682
  };
1682
- var _hoisted_2$11 = { class: "flex gap-3 h-full p-1 min-w-max" };
1683
- var _hoisted_3$11 = ["data-testid"];
1684
- var _hoisted_4$11 = { class: "flex items-center justify-between px-3 py-2 border-b border-slate-200" };
1685
- var _hoisted_5$10 = { class: "flex items-center gap-2 min-w-0" };
1686
- var _hoisted_6$9 = ["title"];
1683
+ var _hoisted_2$12 = { class: "flex gap-3 h-full p-1 min-w-max" };
1684
+ var _hoisted_3$12 = ["data-testid"];
1685
+ var _hoisted_4$12 = { class: "flex items-center justify-between px-3 py-2 border-b border-slate-200" };
1686
+ var _hoisted_5$11 = { class: "flex items-center gap-2 min-w-0" };
1687
+ var _hoisted_6$10 = ["title"];
1687
1688
  var _hoisted_7$8 = { class: "text-[11px] text-slate-400 shrink-0" };
1688
1689
  var _hoisted_8$8 = [
1689
1690
  "data-testid",
@@ -1717,7 +1718,10 @@ var CollectionKanbanView_default = /* @__PURE__ */ defineComponent({
1717
1718
  const emit = __emit;
1718
1719
  const { t } = useCollectionI18n();
1719
1720
  /** The Uncategorized column uses the empty string as its sentinel value. */
1720
- const groupSpec = computed(() => props.schema.fields[props.groupField]);
1721
+ const groupSpec = computed(() => {
1722
+ const spec = props.schema.fields[props.groupField];
1723
+ return spec?.type === "enum" ? spec : void 0;
1724
+ });
1721
1725
  /** Declared enum values become columns in order, with a trailing
1722
1726
  * Uncategorized column for empty/unknown values (also a drop target that
1723
1727
  * clears the field). The Uncategorized column is omitted when the group
@@ -1764,7 +1768,10 @@ var CollectionKanbanView_default = /* @__PURE__ */ defineComponent({
1764
1768
  const value = String(raw);
1765
1769
  return (groupSpec.value?.values ?? []).includes(value) ? value : UNCATEGORIZED;
1766
1770
  }
1767
- const visibleItems = computed(() => groupSpec.value ? props.items.filter((item) => fieldVisible(groupSpec.value, item)) : []);
1771
+ const visibleItems = computed(() => {
1772
+ const spec = groupSpec.value;
1773
+ return spec ? props.items.filter((item) => fieldVisible(spec, item)) : [];
1774
+ });
1768
1775
  const itemsByColumnMap = computed(() => {
1769
1776
  const map = /* @__PURE__ */ new Map();
1770
1777
  for (const column of columns.value) map.set(column.value, []);
@@ -1796,15 +1803,15 @@ var CollectionKanbanView_default = /* @__PURE__ */ defineComponent({
1796
1803
  emit("move", itemId(item), next);
1797
1804
  }
1798
1805
  return (_ctx, _cache) => {
1799
- return openBlock(), createElementBlock("div", _hoisted_1$12, [createElementVNode("div", _hoisted_2$11, [(openBlock(true), createElementBlock(Fragment, null, renderList(columns.value, (column) => {
1806
+ return openBlock(), createElementBlock("div", _hoisted_1$13, [createElementVNode("div", _hoisted_2$12, [(openBlock(true), createElementBlock(Fragment, null, renderList(columns.value, (column) => {
1800
1807
  return openBlock(), createElementBlock("div", {
1801
1808
  key: column.value,
1802
1809
  "data-testid": `collection-kanban-column-${column.value || "uncategorized"}`,
1803
1810
  class: "w-72 shrink-0 flex flex-col bg-slate-100 rounded-lg"
1804
- }, [createElementVNode("div", _hoisted_4$11, [createElementVNode("div", _hoisted_5$10, [createElementVNode("span", { class: normalizeClass(["w-2 h-2 rounded-full shrink-0", unref(resolveEnumColor)(__props.schema, __props.groupField, column.value).dot]) }, null, 2), createElementVNode("span", {
1811
+ }, [createElementVNode("div", _hoisted_4$12, [createElementVNode("div", _hoisted_5$11, [createElementVNode("span", { class: normalizeClass(["w-2 h-2 rounded-full shrink-0", unref(resolveEnumColor)(__props.schema, __props.groupField, column.value).dot]) }, null, 2), createElementVNode("span", {
1805
1812
  class: "font-semibold text-xs text-slate-600 truncate",
1806
1813
  title: column.label
1807
- }, toDisplayString(column.label), 9, _hoisted_6$9)]), createElementVNode("span", _hoisted_7$8, toDisplayString(itemsByColumn(column.value).length), 1)]), createVNode(unref(draggable), {
1814
+ }, toDisplayString(column.label), 9, _hoisted_6$10)]), createElementVNode("span", _hoisted_7$8, toDisplayString(itemsByColumn(column.value).length), 1)]), createVNode(unref(draggable), {
1808
1815
  "model-value": itemsByColumn(column.value),
1809
1816
  "item-key": __props.schema.primaryKey,
1810
1817
  group: "collection-kanban-cards",
@@ -1835,7 +1842,7 @@ var CollectionKanbanView_default = /* @__PURE__ */ defineComponent({
1835
1842
  "model-value",
1836
1843
  "item-key",
1837
1844
  "onChange"
1838
- ])], 8, _hoisted_3$11);
1845
+ ])], 8, _hoisted_3$12);
1839
1846
  }), 128))])]);
1840
1847
  };
1841
1848
  }
@@ -1864,6 +1871,61 @@ function activatePathLink(event, path, stop = false) {
1864
1871
  nav(path);
1865
1872
  }
1866
1873
  //#endregion
1874
+ //#region src/vue/components/CollectionBacklinksView.vue?vue&type=script&setup=true&lang.ts
1875
+ var _hoisted_1$12 = ["data-testid"];
1876
+ var _hoisted_2$11 = { class: "w-full text-[11px] text-slate-600 bg-white" };
1877
+ var _hoisted_3$11 = { class: "bg-slate-50 border-b border-slate-200 text-slate-500 font-bold uppercase tracking-wider" };
1878
+ var _hoisted_4$11 = { class: "divide-y divide-slate-100" };
1879
+ var _hoisted_5$10 = [
1880
+ "data-testid",
1881
+ "onClick",
1882
+ "onKeydown"
1883
+ ];
1884
+ var _hoisted_6$9 = ["data-testid"];
1885
+ //#endregion
1886
+ //#region src/vue/components/CollectionBacklinksView.vue
1887
+ var CollectionBacklinksView_default = /* @__PURE__ */ defineComponent({
1888
+ __name: "CollectionBacklinksView",
1889
+ props: {
1890
+ view: {},
1891
+ fieldKey: {}
1892
+ },
1893
+ setup(__props) {
1894
+ const { t } = useCollectionI18n();
1895
+ return (_ctx, _cache) => {
1896
+ return __props.view.rows.length > 0 ? (openBlock(), createElementBlock("div", {
1897
+ key: 0,
1898
+ class: "border border-slate-200/80 rounded-xl overflow-hidden shadow-sm mt-1",
1899
+ "data-testid": `collections-backlinks-${__props.fieldKey}`
1900
+ }, [createElementVNode("table", _hoisted_2$11, [createElementVNode("thead", _hoisted_3$11, [createElementVNode("tr", null, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.view.columns, (column) => {
1901
+ return openBlock(), createElementBlock("th", {
1902
+ key: column.key,
1903
+ class: "text-left px-4 py-2 font-bold"
1904
+ }, toDisplayString(column.label), 1);
1905
+ }), 128))])]), createElementVNode("tbody", _hoisted_4$11, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.view.rows, (row) => {
1906
+ return openBlock(), createElementBlock("tr", {
1907
+ key: row.id,
1908
+ class: "group hover:bg-indigo-50/30 cursor-pointer transition-colors",
1909
+ role: "link",
1910
+ tabindex: "0",
1911
+ "data-testid": `collections-backlinks-${__props.fieldKey}-${row.id}`,
1912
+ onClick: ($event) => unref(activateRefLink)($event, __props.view.fromSlug, row.id),
1913
+ onKeydown: [withKeys(($event) => unref(activateRefLink)($event, __props.view.fromSlug, row.id), ["enter"]), withKeys(($event) => unref(activateRefLink)($event, __props.view.fromSlug, row.id), ["space"])]
1914
+ }, [(openBlock(true), createElementBlock(Fragment, null, renderList(row.cells, (cell, cellIdx) => {
1915
+ return openBlock(), createElementBlock("td", {
1916
+ key: __props.view.columns[cellIdx]?.key ?? cellIdx,
1917
+ class: "px-4 py-2 align-middle font-medium"
1918
+ }, [createElementVNode("span", { class: normalizeClass(cellIdx === 0 ? "text-indigo-600 group-hover:text-indigo-800 font-bold" : "") }, toDisplayString(cell), 3)]);
1919
+ }), 128))], 40, _hoisted_5$10);
1920
+ }), 128))])])], 8, _hoisted_1$12)) : (openBlock(), createElementBlock("span", {
1921
+ key: 1,
1922
+ class: "text-slate-400 italic",
1923
+ "data-testid": `collections-backlinks-${__props.fieldKey}`
1924
+ }, toDisplayString(unref(t)("collectionsView.noRows")), 9, _hoisted_6$9));
1925
+ };
1926
+ }
1927
+ });
1928
+ //#endregion
1867
1929
  //#region src/vue/components/CollectionEmbedView.vue?vue&type=script&setup=true&lang.ts
1868
1930
  var _hoisted_1$11 = [
1869
1931
  "href",
@@ -2164,7 +2226,7 @@ var _hoisted_71$1 = [
2164
2226
  "onClick"
2165
2227
  ];
2166
2228
  var _hoisted_72$1 = {
2167
- key: 13,
2229
+ key: 14,
2168
2230
  class: "text-slate-800 font-semibold"
2169
2231
  };
2170
2232
  var _hoisted_73$1 = {
@@ -2237,6 +2299,7 @@ var CollectionRecordPanel_default = /* @__PURE__ */ defineComponent({
2237
2299
  * safe in the template. */
2238
2300
  const detailRecord = computed(() => editing.value ? props.liveDerived ?? props.liveRecord ?? {} : props.viewing ?? {});
2239
2301
  const embedViews = computed(() => props.render.embedViewsFor(detailRecord.value));
2302
+ const backlinksViews = computed(() => props.render.backlinksViewsFor(detailRecord.value));
2240
2303
  const embedOwnerByKey = computed(() => {
2241
2304
  const map = /* @__PURE__ */ new Map();
2242
2305
  for (const field of Object.values(props.collection.schema.fields)) if (field.type === "embed" && field.idField) map.set(field.idField, field);
@@ -2253,11 +2316,12 @@ var CollectionRecordPanel_default = /* @__PURE__ */ defineComponent({
2253
2316
  return editing.value.mode === "create" ? "collections-create" : "collections-edit";
2254
2317
  });
2255
2318
  /** Whether a field gets an editable control in edit mode. Toggle (a
2256
- * projection of an enum that has its own input), derived (computed), and
2257
- * embed (a foreign record) stay read-only in both modes, so the cell
2258
- * geometry never changes on the view↔edit toggle. */
2319
+ * projection of an enum that has its own input), derived (computed),
2320
+ * embed (a foreign record), and backlinks (reverse refs owned by OTHER
2321
+ * records) stay read-only in both modes, so the cell geometry never
2322
+ * changes on the view↔edit toggle. */
2259
2323
  function isEditableType(type) {
2260
- return type !== "toggle" && type !== "derived" && type !== "embed";
2324
+ return type !== "toggle" && type !== "derived" && type !== "embed" && type !== "backlinks";
2261
2325
  }
2262
2326
  /** Wide field types span the full grid width in BOTH modes — keeping
2263
2327
  * `image` full-width here (not just when viewing) is what stops a field
@@ -2267,6 +2331,7 @@ var CollectionRecordPanel_default = /* @__PURE__ */ defineComponent({
2267
2331
  "table",
2268
2332
  "markdown",
2269
2333
  "embed",
2334
+ "backlinks",
2270
2335
  "image"
2271
2336
  ].includes(field.type) ? "col-span-full" : "col-span-1";
2272
2337
  }
@@ -2291,7 +2356,8 @@ var CollectionRecordPanel_default = /* @__PURE__ */ defineComponent({
2291
2356
  /** Required flag for an embed's per-record picker — read off the storage
2292
2357
  * field it writes (`idField`), since the embed itself stores nothing. */
2293
2358
  function embedPickerRequired(field) {
2294
- const target = field.idField ? props.collection.schema.fields[field.idField] : void 0;
2359
+ const idField = field.type === "embed" ? field.idField : void 0;
2360
+ const target = idField ? props.collection.schema.fields[idField] : void 0;
2295
2361
  return target ? isFieldRequiredInUi(target) : false;
2296
2362
  }
2297
2363
  /** Tailwind fill/text/border classes tinting an enum `<select>` by its current
@@ -2568,28 +2634,32 @@ var CollectionRecordPanel_default = /* @__PURE__ */ defineComponent({
2568
2634
  key: 8,
2569
2635
  view: embedViews.value[key],
2570
2636
  "field-key": String(key)
2571
- }, null, 8, ["view", "field-key"])) : field.type === "image" && typeof detailRecord.value[key] === "string" && detailRecord.value[key] ? (openBlock(), createElementBlock("img", {
2637
+ }, null, 8, ["view", "field-key"])) : field.type === "backlinks" && backlinksViews.value[key] ? (openBlock(), createBlock(CollectionBacklinksView_default, {
2572
2638
  key: 9,
2639
+ view: backlinksViews.value[key],
2640
+ "field-key": String(key)
2641
+ }, null, 8, ["view", "field-key"])) : field.type === "image" && typeof detailRecord.value[key] === "string" && detailRecord.value[key] ? (openBlock(), createElementBlock("img", {
2642
+ key: 10,
2573
2643
  src: unref(resolveImageSrc)(String(detailRecord.value[key])),
2574
2644
  alt: field.label,
2575
2645
  class: "max-h-64 max-w-full object-contain rounded-lg border border-slate-200 bg-slate-50",
2576
2646
  "data-testid": `collections-detail-image-${key}`
2577
2647
  }, null, 8, _hoisted_68$1)) : field.type !== "file" && __props.render.isExternalUrl(detailRecord.value[key]) ? (openBlock(), createElementBlock("a", {
2578
- key: 10,
2648
+ key: 11,
2579
2649
  href: String(detailRecord.value[key]),
2580
2650
  target: "_blank",
2581
2651
  rel: "noopener noreferrer",
2582
2652
  class: "text-blue-600 hover:text-blue-800 font-semibold hover:underline break-all",
2583
2653
  "data-testid": `collections-detail-url-${key}`
2584
2654
  }, toDisplayString(String(detailRecord.value[key])), 9, _hoisted_69$1)) : field.type === "file" && __props.render.artifactUrl(detailRecord.value[key]) ? (openBlock(), createElementBlock("a", {
2585
- key: 11,
2655
+ key: 12,
2586
2656
  href: __props.render.artifactUrl(detailRecord.value[key]) ?? void 0,
2587
2657
  target: "_blank",
2588
2658
  rel: "noopener noreferrer",
2589
2659
  class: "text-blue-600 hover:text-blue-800 font-semibold hover:underline break-all",
2590
2660
  "data-testid": `collections-detail-file-${key}`
2591
2661
  }, toDisplayString(String(detailRecord.value[key])), 9, _hoisted_70$1)) : field.type === "file" && __props.render.fileRoutePath(detailRecord.value[key]) ? (openBlock(), createElementBlock("a", {
2592
- key: 12,
2662
+ key: 13,
2593
2663
  href: __props.render.fileRoutePath(detailRecord.value[key]) ?? void 0,
2594
2664
  class: "text-blue-600 hover:text-blue-800 font-semibold hover:underline break-all",
2595
2665
  "data-testid": `collections-detail-file-${key}`,
@@ -3043,8 +3113,11 @@ function formatCell(value, type) {
3043
3113
  }
3044
3114
  /** Resolve the ISO 4217 code for a money field: a per-record
3045
3115
  * `currencyField` (when present and non-blank) wins over the field's
3046
- * literal `currency`. */
3116
+ * literal `currency`. Only `money` / `derived` variants carry currency
3117
+ * keys; any other field resolves to undefined (the formatter's USD
3118
+ * fallback), as before. */
3047
3119
  function resolveCurrency(field, record) {
3120
+ if (field.type !== "money" && field.type !== "derived") return void 0;
3048
3121
  if (field.currencyField && record) {
3049
3122
  const code = record[field.currencyField];
3050
3123
  if (typeof code === "string" && code.trim().length > 0) return code;
@@ -3106,6 +3179,14 @@ function uniqueEmbedTargets(schema) {
3106
3179
  for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
3107
3180
  return [...targets];
3108
3181
  }
3182
+ /** Slugs of every SOURCE collection a `backlinks` field reverses over.
3183
+ * Top-level only, like `embed` (the schema rejects `backlinks` inside a
3184
+ * table's `of`). Mirrors the server's `uniqueBacklinkSources`. */
3185
+ function uniqueBacklinkSources(schema) {
3186
+ const sources = /* @__PURE__ */ new Set();
3187
+ for (const field of Object.values(schema.fields)) if (field.type === "backlinks" && field.from.length > 0) sources.add(field.from);
3188
+ return [...sources];
3189
+ }
3109
3190
  function buildRefDisplayMap(detail) {
3110
3191
  const { fields, primaryKey } = detail.collection.schema;
3111
3192
  const displayField = displayFieldFor(fields, primaryKey);
@@ -3118,15 +3199,23 @@ function buildRefDisplayMap(detail) {
3118
3199
  }
3119
3200
  return map;
3120
3201
  }
3121
- function buildRefRecordMap(detail) {
3122
- const { schema } = detail.collection;
3202
+ /** Index DERIVED records by primary key — the client mirror of the
3203
+ * server's `loadTarget` indexing: string non-empty ids only, one record
3204
+ * per id (a duplicate keeps the LAST record in its FIRST position —
3205
+ * plain-object key semantics). Shared by the ref-record cache and the
3206
+ * backlinks view so every client consumer agrees with server enrichment
3207
+ * on which source records exist. */
3208
+ function derivedRecordsById(schema, items) {
3123
3209
  const map = {};
3124
- for (const item of detail.items) {
3210
+ for (const item of items) {
3125
3211
  const slugRaw = item[schema.primaryKey];
3126
3212
  if (typeof slugRaw === "string" && slugRaw.length > 0) map[slugRaw] = deriveAll(schema, item, {});
3127
3213
  }
3128
3214
  return map;
3129
3215
  }
3216
+ function buildRefRecordMap(detail) {
3217
+ return derivedRecordsById(detail.collection.schema, detail.items);
3218
+ }
3130
3219
  function sortedRefOptions(map) {
3131
3220
  return Object.entries(map).map(([slug, display]) => ({
3132
3221
  slug,
@@ -3149,8 +3238,64 @@ function buildEmbedOptions(schema, items) {
3149
3238
  }).filter((opt) => opt.slug.length > 0).sort((left, right) => left.display.localeCompare(right.display));
3150
3239
  }
3151
3240
  //#endregion
3152
- //#region src/vue/useCollectionRendering.ts
3153
- function useCollectionRendering(collection, locale) {
3241
+ //#region src/vue/useLinkedCollectionCaches.ts
3242
+ /** The de-duplicated ref + embed target slugs a schema links to. `allTargets`
3243
+ * is the union (each target fetched once even when both ref'd and embedded).
3244
+ * `backlinks` SOURCE collections ride in `embedTargets`: a backlink needs
3245
+ * exactly what an embed target caches (the source's schema + items in
3246
+ * `embedCache`), so reverse sources reuse that cache rather than adding a
3247
+ * parallel one. */
3248
+ function linkedTargets(schema) {
3249
+ const refTargets = new Set(uniqueRefTargets(schema));
3250
+ const embedTargets = /* @__PURE__ */ new Set([...uniqueEmbedTargets(schema), ...uniqueBacklinkSources(schema)]);
3251
+ return {
3252
+ refTargets,
3253
+ embedTargets,
3254
+ allTargets: [.../* @__PURE__ */ new Set([...refTargets, ...embedTargets])]
3255
+ };
3256
+ }
3257
+ /** Fan-out fetch that hydrates the linked-collection caches. Best-effort: a
3258
+ * target whose fetch *rejects* (vs. resolving `{ ok: false }`) is coerced to a
3259
+ * skip and must not abort the others. Returns null when a quicker subsequent
3260
+ * load has already moved on (stale-write guard) so the caller drops the write.
3261
+ * Pure + injectable (`fetchDetail`, `currentSlug`) so both paths are testable. */
3262
+ async function fetchLinkedCaches(targets, fetchDetail, currentSlug, expectedSlug) {
3263
+ const { refTargets, embedTargets, allTargets } = targets;
3264
+ const results = await Promise.all(allTargets.map(async (target) => {
3265
+ try {
3266
+ return {
3267
+ target,
3268
+ result: await fetchDetail(target)
3269
+ };
3270
+ } catch {
3271
+ return {
3272
+ target,
3273
+ result: { ok: false }
3274
+ };
3275
+ }
3276
+ }));
3277
+ if (currentSlug() !== expectedSlug) return null;
3278
+ const refCache = {};
3279
+ const refRecordCache = {};
3280
+ const embedCache = {};
3281
+ for (const { target, result } of results) {
3282
+ if (!result.ok) continue;
3283
+ if (refTargets.has(target)) {
3284
+ refCache[target] = buildRefDisplayMap(result.data);
3285
+ refRecordCache[target] = buildRefRecordMap(result.data);
3286
+ }
3287
+ if (embedTargets.has(target)) embedCache[target] = {
3288
+ schema: result.data.collection.schema,
3289
+ items: result.data.items
3290
+ };
3291
+ }
3292
+ return {
3293
+ refCache,
3294
+ refRecordCache,
3295
+ embedCache
3296
+ };
3297
+ }
3298
+ function useLinkedCollectionCaches(collection) {
3154
3299
  const refCache = ref({});
3155
3300
  const refRecordCache = ref({});
3156
3301
  const embedCache = ref({});
@@ -3160,161 +3305,192 @@ function useCollectionRendering(collection, locale) {
3160
3305
  embedCache.value = {};
3161
3306
  }
3162
3307
  async function loadLinkedCollections(schema, expectedSlug) {
3163
- const refTargets = new Set(uniqueRefTargets(schema));
3164
- const embedTargets = new Set(uniqueEmbedTargets(schema));
3165
- const allTargets = [.../* @__PURE__ */ new Set([...refTargets, ...embedTargets])];
3166
- if (allTargets.length === 0) return;
3308
+ const targets = linkedTargets(schema);
3309
+ if (targets.allTargets.length === 0) return;
3167
3310
  const binding = collectionUi();
3168
- const results = await Promise.all(allTargets.map(async (target) => {
3169
- try {
3170
- return {
3171
- target,
3172
- result: await binding.fetchCollectionDetail(target)
3173
- };
3174
- } catch {
3175
- return {
3176
- target,
3177
- result: { ok: false }
3178
- };
3179
- }
3180
- }));
3181
- if (collection.value?.slug !== expectedSlug) return;
3182
- const nextRef = {};
3183
- const nextRefRecords = {};
3184
- const nextEmbed = {};
3185
- for (const { target, result } of results) {
3186
- if (!result.ok) continue;
3187
- if (refTargets.has(target)) {
3188
- nextRef[target] = buildRefDisplayMap(result.data);
3189
- nextRefRecords[target] = buildRefRecordMap(result.data);
3190
- }
3191
- if (embedTargets.has(target)) nextEmbed[target] = {
3192
- schema: result.data.collection.schema,
3193
- items: result.data.items
3194
- };
3195
- }
3196
- refCache.value = nextRef;
3197
- refRecordCache.value = nextRefRecords;
3198
- embedCache.value = nextEmbed;
3199
- }
3200
- function refDisplay(targetSlug, itemSlug) {
3201
- const map = refCache.value[targetSlug];
3202
- return map && map[itemSlug] || itemSlug;
3203
- }
3204
- function refOptions(targetSlug) {
3205
- const map = refCache.value[targetSlug];
3206
- return map ? sortedRefOptions(map) : [];
3207
- }
3208
- /** Dropdown options for an `embed` field's per-record picker (`idField`):
3209
- * every record in the target collection, labelled by its name/title (or
3210
- * primary key). Built from `embedCache` so it works for embed targets
3211
- * that aren't also `ref` targets (the profile collection, say). */
3212
- function embedOptions(targetSlug) {
3213
- const data = embedCache.value[targetSlug];
3214
- return data ? buildEmbedOptions(data.schema, data.items) : [];
3311
+ const snapshot = await fetchLinkedCaches(targets, (slug) => binding.fetchCollectionDetail(slug), () => collection.value?.slug, expectedSlug);
3312
+ if (!snapshot) return;
3313
+ refCache.value = snapshot.refCache;
3314
+ refRecordCache.value = snapshot.refRecordCache;
3315
+ embedCache.value = snapshot.embedCache;
3215
3316
  }
3216
- function resolveEmbed(field, record) {
3217
- if (field.type !== "embed" || !field.to) return {
3218
- schema: null,
3219
- item: null
3220
- };
3221
- const targetId = embedTargetId(field, record);
3222
- const data = targetId ? embedCache.value[field.to] : void 0;
3223
- if (!data) return {
3224
- schema: null,
3225
- item: null
3226
- };
3227
- const item = data.items.find((entry) => String(entry[data.schema.primaryKey] ?? "") === targetId) ?? null;
3228
- return {
3229
- schema: data.schema,
3230
- item
3317
+ return {
3318
+ refCache,
3319
+ refRecordCache,
3320
+ embedCache,
3321
+ resetLinkedCaches,
3322
+ loadLinkedCollections
3323
+ };
3324
+ }
3325
+ //#endregion
3326
+ //#region src/vue/useCollectionRendering.renderers.ts
3327
+ function lookupRefDisplay(refCache, targetSlug, itemSlug) {
3328
+ const map = refCache[targetSlug];
3329
+ return map && map[itemSlug] || itemSlug;
3330
+ }
3331
+ function refOptionsFor(refCache, targetSlug) {
3332
+ const map = refCache[targetSlug];
3333
+ return map ? sortedRefOptions(map) : [];
3334
+ }
3335
+ /** Dropdown options for an `embed` field's per-record picker (`idField`):
3336
+ * every record in the target collection, labelled by its name/title (or
3337
+ * primary key). Built from `embedCache` so it works for embed targets
3338
+ * that aren't also `ref` targets (the profile collection, say). */
3339
+ function embedOptionsFor(embedCache, targetSlug) {
3340
+ const data = embedCache[targetSlug];
3341
+ return data ? buildEmbedOptions(data.schema, data.items) : [];
3342
+ }
3343
+ function resolveEmbed(field, record, embedCache) {
3344
+ if (field.type !== "embed" || !field.to) return {
3345
+ schema: null,
3346
+ item: null
3347
+ };
3348
+ const targetId = embedTargetId(field, record);
3349
+ const data = targetId ? embedCache[field.to] : void 0;
3350
+ if (!data) return {
3351
+ schema: null,
3352
+ item: null
3353
+ };
3354
+ const item = data.items.find((entry) => String(entry[data.schema.primaryKey] ?? "") === targetId) ?? null;
3355
+ return {
3356
+ schema: data.schema,
3357
+ item
3358
+ };
3359
+ }
3360
+ function formatEmbedValue(field, value, record, locale) {
3361
+ if (field.type === "money") return formatMoney(value, resolveCurrency(field, record), locale);
3362
+ return detailText(value);
3363
+ }
3364
+ /** Build the read-only embed view-models for one record. A function of the
3365
+ * open record (not a bare computed) because a per-record `idField` embed
3366
+ * resolves a different target per row. `schema` is the OPEN collection's
3367
+ * schema (null when no collection is loaded → no views). */
3368
+ function buildEmbedViews(schema, embedCache, record, locale) {
3369
+ const out = {};
3370
+ if (!schema) return out;
3371
+ for (const [key, field] of Object.entries(schema.fields)) {
3372
+ if (field.type !== "embed") continue;
3373
+ const { schema: targetSchema, item } = resolveEmbed(field, record, embedCache);
3374
+ const rows = [];
3375
+ if (targetSchema && item) for (const [subKey, subField] of Object.entries(targetSchema.fields)) {
3376
+ const value = item[subKey];
3377
+ if (value === void 0 || value === null || value === "") continue;
3378
+ rows.push({
3379
+ key: subKey,
3380
+ label: subField.label,
3381
+ type: subField.type,
3382
+ value,
3383
+ display: formatEmbedValue(subField, value, item, locale)
3384
+ });
3385
+ }
3386
+ out[key] = {
3387
+ found: Boolean(item),
3388
+ rows,
3389
+ targetSlug: field.to ?? "",
3390
+ recordId: embedTargetId(field, record)
3231
3391
  };
3232
3392
  }
3233
- function embedValue(field, value, record) {
3234
- if (field.type === "money") return formatMoney(value, resolveCurrency(field, record), locale.value);
3235
- return detailText(value);
3236
- }
3237
- /** Build the read-only embed view-models for one record. A function of
3238
- * the open record (not a bare computed) because a per-record `idField`
3239
- * embed resolves a different target per row. */
3240
- function embedViewsFor(record) {
3241
- const out = {};
3242
- if (!collection.value) return out;
3243
- for (const [key, field] of Object.entries(collection.value.schema.fields)) {
3244
- if (field.type !== "embed") continue;
3245
- const { schema, item } = resolveEmbed(field, record);
3246
- const rows = [];
3247
- if (schema && item) for (const [subKey, subField] of Object.entries(schema.fields)) {
3248
- const value = item[subKey];
3249
- if (value === void 0 || value === null || value === "") continue;
3250
- rows.push({
3251
- key: subKey,
3252
- label: subField.label,
3253
- type: subField.type,
3254
- value,
3255
- display: embedValue(subField, value, item)
3256
- });
3257
- }
3393
+ return out;
3394
+ }
3395
+ /** One backlinks table CELL: money formatted with its currency, anything
3396
+ * else through the same `formatCell` list tables use — so a markdown
3397
+ * source column (a worklog `notes`) shows the one-line 80-char preview,
3398
+ * not the whole text. Unknown `display` key plain text (fail-soft). */
3399
+ function formatBacklinkCell(sourceField, value, row, locale) {
3400
+ if (!sourceField) return detailText(value);
3401
+ if (sourceField.type === "money") return formatMoney(value, resolveCurrency(sourceField, row), locale);
3402
+ return formatCell(value, sourceField.type);
3403
+ }
3404
+ /** Build the read-only backlinks view-models for one record: for each
3405
+ * `backlinks` field, the rows of `from` whose `via` points at the open
3406
+ * record (matched via the SHARED `backlinkRows`, on source records
3407
+ * derived exactly like the server's enrichment so a `display`/`filter`
3408
+ * on a derived column such as an invoice `total` agrees on both sides).
3409
+ * Source data comes out of `embedCache` (reverse sources ride the embed
3410
+ * fan-out — see `linkedTargets`). Fail-soft: an unloadable source or an
3411
+ * unknown `display` key degrades to `found: false` / a raw-key column,
3412
+ * never a throw. */
3413
+ function buildBacklinksViews(schema, embedCache, record, locale) {
3414
+ const out = {};
3415
+ if (!schema) return out;
3416
+ for (const [key, field] of Object.entries(schema.fields)) {
3417
+ if (field.type !== "backlinks") continue;
3418
+ const columns = field.display.map((col) => ({
3419
+ key: col,
3420
+ label: col
3421
+ }));
3422
+ const data = embedCache[field.from];
3423
+ if (!data) {
3258
3424
  out[key] = {
3259
- found: Boolean(item),
3260
- rows,
3261
- targetSlug: field.to ?? "",
3262
- recordId: embedTargetId(field, record)
3425
+ found: false,
3426
+ columns,
3427
+ rows: [],
3428
+ fromSlug: field.from
3263
3429
  };
3264
- }
3265
- return out;
3266
- }
3267
- function currencySymbol(currency) {
3268
- return currencySymbolForLocale(currency, locale.value);
3269
- }
3270
- function artifactUrl(value) {
3271
- return collectionUi().fileAssetUrl(value);
3272
- }
3273
- function fileRoutePath(value) {
3274
- return collectionUi().fileRoutePath(value);
3275
- }
3276
- function formatSubCell(subField, value, record) {
3277
- if (subField.type === "money") return formatMoney(value, resolveCurrency(subField, record), locale.value);
3278
- if (subField.type === "ref" && subField.to && typeof value === "string" && value.length > 0) return refDisplay(subField.to, value);
3279
- return formatCell(value, subField.type);
3280
- }
3281
- const stepFor = stepForFieldType;
3282
- function evaluateDerivedAgainstItem(field, fieldKey, item) {
3283
- if (!field.formula || !collection.value) return null;
3284
- const result = deriveAll(collection.value.schema, item, refRecordCache.value)[fieldKey];
3285
- return typeof result === "number" && Number.isFinite(result) ? result : null;
3286
- }
3287
- function derivedDisplay(field, computedValue, record) {
3288
- if (computedValue === null || computedValue === void 0) return "—";
3289
- if (field.display === "money") return formatMoney(computedValue, resolveCurrency(field, record), locale.value);
3290
- return formatCell(computedValue, field.display ?? "number");
3430
+ continue;
3431
+ }
3432
+ for (const column of columns) column.label = data.schema.fields[column.key]?.label ?? column.key;
3433
+ const selfId = String(record?.[schema.primaryKey] ?? "");
3434
+ const sourceById = derivedRecordsById(data.schema, data.items);
3435
+ out[key] = {
3436
+ found: true,
3437
+ columns,
3438
+ rows: backlinkRows(field, selfId, Object.values(sourceById)).map((row) => ({
3439
+ id: String(row[data.schema.primaryKey] ?? ""),
3440
+ cells: columns.map((column) => formatBacklinkCell(data.schema.fields[column.key], row[column.key], row, locale))
3441
+ })),
3442
+ fromSlug: field.from
3443
+ };
3291
3444
  }
3445
+ return out;
3446
+ }
3447
+ function renderSubCell(subField, value, record, refCache, locale) {
3448
+ if (subField.type === "money") return formatMoney(value, resolveCurrency(subField, record), locale);
3449
+ if (subField.type === "ref" && subField.to && typeof value === "string" && value.length > 0) return lookupRefDisplay(refCache, subField.to, value);
3450
+ return formatCell(value, subField.type);
3451
+ }
3452
+ function evaluateDerived(field, fieldKey, item, schema, refRecords) {
3453
+ if (field.type !== "derived" || !schema) return null;
3454
+ const result = deriveAll(schema, item, refRecords)[fieldKey];
3455
+ return typeof result === "number" && Number.isFinite(result) ? result : null;
3456
+ }
3457
+ function renderDerived(field, computedValue, record, locale) {
3458
+ if (computedValue === null || computedValue === void 0) return "—";
3459
+ const display = field.type === "derived" ? field.display : void 0;
3460
+ if (display === "money") return formatMoney(computedValue, resolveCurrency(field, record), locale);
3461
+ return formatCell(computedValue, display ?? "number");
3462
+ }
3463
+ //#endregion
3464
+ //#region src/vue/useCollectionRendering.ts
3465
+ var STATELESS_RENDERERS = {
3466
+ resolveCurrency,
3467
+ formatMoney,
3468
+ formatCell,
3469
+ detailText,
3470
+ isExternalUrl,
3471
+ tableRows,
3472
+ hasTableRows,
3473
+ inputTypeFor,
3474
+ stepFor: stepForFieldType,
3475
+ deriveAll
3476
+ };
3477
+ function useCollectionRendering(collection, locale) {
3478
+ const caches = useLinkedCollectionCaches(collection);
3479
+ const { refCache, refRecordCache, embedCache } = caches;
3292
3480
  return {
3293
- refCache,
3294
- refRecordCache,
3295
- embedCache,
3296
- resetLinkedCaches,
3297
- loadLinkedCollections,
3298
- refDisplay,
3299
- refOptions,
3300
- embedOptions,
3301
- embedViewsFor,
3302
- resolveCurrency,
3303
- currencySymbol,
3304
- formatMoney,
3305
- formatCell,
3306
- detailText,
3307
- isExternalUrl,
3308
- artifactUrl,
3309
- fileRoutePath,
3310
- tableRows,
3311
- hasTableRows,
3312
- formatSubCell,
3313
- inputTypeFor,
3314
- stepFor,
3315
- deriveAll,
3316
- evaluateDerivedAgainstItem,
3317
- derivedDisplay
3481
+ ...caches,
3482
+ ...STATELESS_RENDERERS,
3483
+ refDisplay: (targetSlug, itemSlug) => lookupRefDisplay(refCache.value, targetSlug, itemSlug),
3484
+ refOptions: (targetSlug) => refOptionsFor(refCache.value, targetSlug),
3485
+ embedOptions: (targetSlug) => embedOptionsFor(embedCache.value, targetSlug),
3486
+ embedViewsFor: (record) => buildEmbedViews(collection.value?.schema ?? null, embedCache.value, record, locale.value),
3487
+ backlinksViewsFor: (record) => buildBacklinksViews(collection.value?.schema ?? null, embedCache.value, record, locale.value),
3488
+ currencySymbol: (currency) => currencySymbolForLocale(currency, locale.value),
3489
+ artifactUrl: (value) => collectionUi().fileAssetUrl(value),
3490
+ fileRoutePath: (value) => collectionUi().fileRoutePath(value),
3491
+ formatSubCell: (subField, value, record) => renderSubCell(subField, value, record, refCache.value, locale.value),
3492
+ evaluateDerivedAgainstItem: (field, fieldKey, item) => evaluateDerived(field, fieldKey, item, collection.value?.schema ?? null, refRecordCache.value),
3493
+ derivedDisplay: (field, computedValue, record) => renderDerived(field, computedValue, record, locale.value)
3318
3494
  };
3319
3495
  }
3320
3496
  //#endregion
@@ -3805,12 +3981,12 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
3805
3981
  return scalarSortValue(field, item[key]);
3806
3982
  }
3807
3983
  /** Derived rows sort by their display type: money/number → numeric,
3808
- * date/datetime → epoch, anything else → the enriched value as a string. */
3984
+ * date → epoch, anything else → the enriched value as a string. */
3809
3985
  function derivedSortValue(field, key, item) {
3810
- const { display } = field;
3986
+ const display = field.type === "derived" ? field.display : void 0;
3811
3987
  if (display === void 0 || display === "number" || display === "money") return numericSortValue(evaluateDerivedAgainstItem(field, key, item));
3812
3988
  const enriched = collection.value ? render.deriveAll(collection.value.schema, item, render.refRecordCache.value) : item;
3813
- if (display === "date" || display === "datetime") return dateSortValue(enriched[key]);
3989
+ if (display === "date") return dateSortValue(enriched[key]);
3814
3990
  return stringSortValue(enriched[key]);
3815
3991
  }
3816
3992
  const sortedItems = computed(() => {
@@ -3988,10 +4164,12 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
3988
4164
  function buildChatSeed(slug, message, itemId) {
3989
4165
  const current = collection.value;
3990
4166
  if (current?.source !== "feed") return itemId ? `/${slug} id=${itemId} ${message}` : `/${slug} ${message}`;
4167
+ const dataPath = current.schema.dataPath ?? `data/feeds/${slug}`;
4168
+ const scoped = itemId ? `(for record \`${itemId}\`) ${message}` : message;
3991
4169
  return t("collectionsView.feedChatSeed", {
3992
4170
  slug,
3993
- dataPath: current.schema.dataPath ?? `data/feeds/${slug}`,
3994
- message: itemId ? `(for record \`${itemId}\`) ${message}` : message
4171
+ dataPath,
4172
+ message: scoped
3995
4173
  });
3996
4174
  }
3997
4175
  /** Start a new general-role chat seeded from the current view. */
@@ -4066,7 +4244,10 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4066
4244
  enumOriginallyEmpty.value = snapshotEmptyEnums(result.data.collection.schema, result.data.items);
4067
4245
  await render.loadLinkedCollections(result.data.collection.schema, slug);
4068
4246
  if (activeSlug.value !== slug) return;
4069
- if (viewing.value) viewing.value = findItemById(String(viewing.value[result.data.collection.schema.primaryKey] ?? "")) ?? null;
4247
+ if (viewing.value) {
4248
+ const openId = String(viewing.value[result.data.collection.schema.primaryKey] ?? "");
4249
+ viewing.value = findItemById(openId) ?? null;
4250
+ }
4070
4251
  }
4071
4252
  function maybeAutoRefreshFeed(slug) {
4072
4253
  if (embedded.value) return;
@@ -4080,7 +4261,7 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4080
4261
  * list table only (a whole embedded record doesn't fit a table cell,
4081
4262
  * and it'd be identical in every row). The detail modal and the edit
4082
4263
  * form iterate the full `schema.fields` so embeds render there too. */
4083
- const listColumnFields = computed(() => collection.value ? Object.entries(collection.value.schema.fields).filter(([key, field]) => field.type !== "embed" && field.type !== "image" && key !== collection.value?.schema.primaryKey) : []);
4264
+ const listColumnFields = computed(() => collection.value ? Object.entries(collection.value.schema.fields).filter(([key, field]) => field.type !== "embed" && field.type !== "backlinks" && field.type !== "image" && key !== collection.value?.schema.primaryKey) : []);
4084
4265
  /** True when the current collection declares `schema.singleton` —
4085
4266
  * exactly one record, its primary key fixed to the declared value. */
4086
4267
  const isSingleton = computed(() => Boolean(collection.value?.schema.singleton));
@@ -4244,7 +4425,8 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4244
4425
  }
4245
4426
  /** Select a custom view by id (builds the `custom:<id>` mode key). */
4246
4427
  function setCustomView(viewId) {
4247
- view.value = `custom:${viewId}`;
4428
+ const mode = `custom:${viewId}`;
4429
+ view.value = mode;
4248
4430
  }
4249
4431
  /** A short, slug-safe id not already used by a loaded record. Collisions
4250
4432
  * are astronomically unlikely (32 bits), but we still re-roll a few
@@ -4268,7 +4450,7 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4268
4450
  boolOriginallyPresent[key] = false;
4269
4451
  boolTouched[key] = false;
4270
4452
  } else if (field.type === "table") table[key] = [];
4271
- else if (field.type !== "derived" && field.type !== "embed" && field.type !== "toggle") text[key] = "";
4453
+ else if (field.type !== "derived" && field.type !== "embed" && field.type !== "backlinks" && field.type !== "toggle") text[key] = "";
4272
4454
  const { singleton, primaryKey } = collection.value.schema;
4273
4455
  if (singleton) text[primaryKey] = singleton;
4274
4456
  else if (primaryKey in text) text[primaryKey] = generateUniqueItemId(primaryKey);
@@ -4300,7 +4482,7 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4300
4482
  } else if (field.type === "table" && field.of) {
4301
4483
  const sub = field.of;
4302
4484
  table[key] = (Array.isArray(raw) ? raw : []).filter((row) => Boolean(row) && typeof row === "object" && !Array.isArray(row)).map((row) => rowFromItem(row, sub));
4303
- } else if (field.type !== "derived" && field.type !== "embed" && field.type !== "toggle") text[key] = raw === void 0 || raw === null ? "" : String(raw);
4485
+ } else if (field.type !== "derived" && field.type !== "embed" && field.type !== "backlinks" && field.type !== "toggle") text[key] = raw === void 0 || raw === null ? "" : String(raw);
4304
4486
  }
4305
4487
  const primaryRaw = item[collection.value.schema.primaryKey];
4306
4488
  const originalId = typeof primaryRaw === "string" ? primaryRaw : String(primaryRaw ?? "");
@@ -4402,7 +4584,8 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4402
4584
  viewing.value = null;
4403
4585
  return;
4404
4586
  }
4405
- viewing.value = findItemById(selected) ?? null;
4587
+ const match = findItemById(selected) ?? null;
4588
+ viewing.value = match;
4406
4589
  }
4407
4590
  /** Title for the open-mode header: the record's primary-key value
4408
4591
  * (e.g. `INV-2026-0001`), falling back to the collection title.
@@ -4496,19 +4679,17 @@ var CollectionView_default = /* @__PURE__ */ defineComponent({
4496
4679
  /** Whether a `toggle` field reads as checked: its projected enum field
4497
4680
  * currently equals `onValue`. The toggle stores nothing itself. */
4498
4681
  function toggleChecked(item, field) {
4499
- return field.field !== void 0 && String(item[field.field] ?? "") === field.onValue;
4682
+ return field.type === "toggle" && String(item[field.field] ?? "") === field.onValue;
4500
4683
  }
4501
4684
  /** Flip a `toggle`: write the projected enum field to `offValue` when
4502
4685
  * currently checked, else `onValue`. Reuses the inline-edit PUT path
4503
4686
  * (optimistic + rollback) — the toggle has no value of its own. */
4504
4687
  function commitToggle(item, field) {
4688
+ if (field.type !== "toggle" || !collection.value) return;
4505
4689
  const targetKey = field.field;
4506
- if (!targetKey || !collection.value) return;
4507
4690
  const enumField = collection.value.schema.fields[targetKey];
4508
4691
  if (!enumField) return;
4509
- const next = toggleChecked(item, field) ? field.offValue : field.onValue;
4510
- if (next === void 0) return;
4511
- commitInlineEdit(item, targetKey, enumField, next);
4692
+ commitInlineEdit(item, targetKey, enumField, toggleChecked(item, field) ? field.offValue : field.onValue);
4512
4693
  }
4513
4694
  async function confirmDelete(item) {
4514
4695
  if (!collection.value) return;