saltcorn-samba 0.4.14 → 0.4.16

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.
@@ -1,88 +1,69 @@
1
1
  /**
2
- * Client-side directory tree for the SambaTree view template.
2
+ * saltcorn-samba Client-Controller für die `SambaTree`-View.
3
3
  *
4
- * Exposes window.saltcornSambaMount(elementId). The tree lazily loads
5
- * children from /sambadir?path=... and opens files either inline (PDF/image)
6
- * in the built-in viewer <div> or by redirecting to /sambalink (smb://).
4
+ * Rendert einen Lazy-loading-Verzeichnisbaum. Kinder werden per Klick über
5
+ * /sambadir?path=... nachgeladen. Klick auf eine Datei öffnet sie entweder
6
+ * inline (PDF/Bilder) im eingebauten Viewer <div> oder springt via /sambalink
7
+ * (smb://) in den nativen Datei-Manager.
8
+ *
9
+ * Utilities (iconFor, joinPath, fmtSize, isViewable, i18n-Übersetzungen)
10
+ * kommen aus dem gemeinsamen Modul `SambaCommon` (public/samba-common.js),
11
+ * das der Server per <script>-Tag vor dieser Datei einbindet.
7
12
  */
8
13
  (function () {
9
14
  "use strict";
10
15
 
11
- function h(tag, attrs, children) {
16
+ var C = window.SambaCommon || {};
17
+
18
+ /**
19
+ * Kleines DOM-Konstruktions-Helferlein. `attrs` unterstützt `class`,
20
+ * `text` (setzt textContent) und beliebige `onXxx`-Handler; alle anderen
21
+ * Attribute landen als HTML-Attribut.
22
+ */
23
+ function element(tag, attrs, children) {
12
24
  var el = document.createElement(tag);
13
- if (attrs)
14
- Object.keys(attrs).forEach(function (k) {
15
- if (k === "class") el.className = attrs[k];
16
- else if (k === "text") el.textContent = attrs[k];
17
- else if (k.slice(0, 2) === "on")
18
- el.addEventListener(k.slice(2).toLowerCase(), attrs[k]);
19
- else el.setAttribute(k, attrs[k]);
25
+ if (attrs) {
26
+ Object.keys(attrs).forEach(function (key) {
27
+ if (key === "class") el.className = attrs[key];
28
+ else if (key === "text") el.textContent = attrs[key];
29
+ else if (key.slice(0, 2) === "on") {
30
+ el.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
31
+ } else {
32
+ el.setAttribute(key, attrs[key]);
33
+ }
20
34
  });
21
- (children || []).forEach(function (c) {
22
- if (c == null) return;
23
- el.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
35
+ }
36
+ (children || []).forEach(function (child) {
37
+ if (child == null) return;
38
+ el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
24
39
  });
25
40
  return el;
26
41
  }
27
42
 
28
- function iconFor(item) {
29
- if (item.isDir) return "📁";
30
- var n = (item.name || "").toLowerCase();
31
- if (n.endsWith(".pdf")) return "📄";
32
- if (/\.(png|jpe?g|gif|webp|svg|bmp)$/.test(n)) return "🖼️";
33
- if (/\.(docx?|odt|rtf|txt|md)$/.test(n)) return "📝";
34
- if (/\.(xlsx?|ods|csv)$/.test(n)) return "📊";
35
- if (/\.(zip|tar|gz|7z|rar)$/.test(n)) return "🗜️";
36
- return "📎";
37
- }
38
-
39
- function isViewable(name) {
40
- var n = (name || "").toLowerCase();
41
- return (
42
- n.endsWith(".pdf") ||
43
- /\.(png|jpe?g|gif|webp|svg|bmp)$/.test(n) ||
44
- /\.(txt|md|json|xml|csv|html?)$/.test(n)
45
- );
46
- }
47
-
48
- function joinPath(a, b) {
49
- if (!a) return b;
50
- if (!b) return a;
51
- return (a.replace(/\/+$/, "") + "/" + b.replace(/^\/+/, "")).replace(
52
- /\/+/g,
53
- "/"
54
- );
55
- }
56
-
57
- function fmtSize(n) {
58
- if (!n) return "";
59
- var u = ["B", "KB", "MB", "GB", "TB"];
60
- var i = 0;
61
- while (n >= 1024 && i < u.length - 1) {
62
- n /= 1024;
63
- i++;
64
- }
65
- return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + " " + u[i];
66
- }
67
-
68
- async function fetchDir(path, showHidden) {
43
+ /** GET /sambadir für ein Verzeichnis. Wirft bei nicht-OK-Antworten. */
44
+ async function fetchDirectory(path, showHidden) {
69
45
  var url =
70
- "/sambadir?path=" +
71
- encodeURIComponent(path || "") +
46
+ "/sambadir?path=" + encodeURIComponent(path || "") +
72
47
  (showHidden ? "&show_hidden=1" : "");
73
- var r = await fetch(url, { credentials: "same-origin" });
74
- if (!r.ok) throw new Error("HTTP " + r.status);
75
- return r.json();
48
+ var response = await fetch(url, { credentials: "same-origin" });
49
+ if (!response.ok) throw new Error("HTTP " + response.status);
50
+ return response.json();
76
51
  }
77
52
 
78
- function renderList(container, path, items, opts, viewerEl) {
53
+ /**
54
+ * Rendert eine Ebene des Baums in `container`. Für jedes Item wird eine
55
+ * Zeile mit Toggle-, Label- und Meta-Span erzeugt; Ordner haben zusätzlich
56
+ * einen leeren Kindercontainer, der bei Bedarf lazy befüllt wird.
57
+ */
58
+ function renderLevel(container, path, items, opts, viewerElement) {
79
59
  container.innerHTML = "";
80
- var ul = h("ul", { class: "samba-tree-list list-unstyled mb-0" }, []);
60
+ var list = element("ul", { class: "samba-tree-list list-unstyled mb-0" }, []);
81
61
 
82
62
  items.forEach(function (item) {
83
- var full = joinPath(path, item.name);
84
- var childrenBox = h("div", { class: "samba-tree-children ms-3" }, []);
85
- var toggle = h(
63
+ var fullPath = C.joinPath(path, item.name);
64
+ var childrenBox = element("div", { class: "samba-tree-children ms-3" }, []);
65
+
66
+ var toggle = element(
86
67
  "span",
87
68
  {
88
69
  class: "samba-tree-toggle me-1",
@@ -91,120 +72,126 @@
91
72
  },
92
73
  []
93
74
  );
94
- var label = h(
75
+ var label = element(
95
76
  "span",
96
77
  {
97
78
  class: "samba-tree-label",
98
- text: iconFor(item) + " " + item.name,
79
+ text: C.iconFor(item) + " " + item.name,
99
80
  style: "cursor:pointer;",
100
- title: full,
81
+ title: fullPath,
101
82
  },
102
83
  []
103
84
  );
104
- var meta = h(
85
+ var meta = element(
105
86
  "span",
106
87
  {
107
88
  class: "samba-tree-meta text-muted small ms-2",
108
- text: item.isDir ? "" : fmtSize(item.size),
89
+ text: item.isDir ? "" : C.fmtSize(item.size),
109
90
  },
110
91
  []
111
92
  );
112
93
 
113
- var externalBtn = null;
94
+ var externalButton = null;
114
95
  if (opts.exposeSmbLink) {
115
- externalBtn = h(
96
+ externalButton = element(
116
97
  "a",
117
98
  {
118
99
  class: "samba-tree-external btn btn-sm btn-link p-0 ms-2",
119
- href: "/sambalink?path=" + encodeURIComponent(full),
100
+ href: "/sambalink?path=" + encodeURIComponent(fullPath),
120
101
  target: "_blank",
121
- title: "Open in file manager",
102
+ title: C.t("tree.open_in_fm_title"),
122
103
  text: "↗",
123
104
  },
124
105
  []
125
106
  );
126
107
  }
127
108
 
128
- var openDir = function () {
109
+ /**
110
+ * Toggle-Handler: erster Klick lädt die Kinder nach, weitere Klicks
111
+ * schalten nur zwischen ausgeklappt/eingeklappt um.
112
+ */
113
+ var toggleDirectory = function () {
129
114
  if (childrenBox.dataset.loaded === "1") {
130
- var vis = childrenBox.style.display !== "none";
131
- childrenBox.style.display = vis ? "none" : "block";
132
- toggle.textContent = vis ? "▸" : "▾";
115
+ var visible = childrenBox.style.display !== "none";
116
+ childrenBox.style.display = visible ? "none" : "block";
117
+ toggle.textContent = visible ? "▸" : "▾";
133
118
  return;
134
119
  }
135
120
  toggle.textContent = "…";
136
- fetchDir(full, opts.showHidden)
121
+ fetchDirectory(fullPath, opts.showHidden)
137
122
  .then(function (data) {
138
- renderList(childrenBox, full, data.items || [], opts, viewerEl);
123
+ renderLevel(childrenBox, fullPath, data.items || [], opts, viewerElement);
139
124
  childrenBox.dataset.loaded = "1";
140
125
  childrenBox.style.display = "block";
141
126
  toggle.textContent = "▾";
142
127
  })
143
- .catch(function (e) {
128
+ .catch(function (err) {
144
129
  childrenBox.innerHTML =
145
- '<div class="text-danger small">Error: ' + e.message + "</div>";
130
+ '<div class="text-danger small">' + C.t("tree.error_prefix") + err.message + "</div>";
146
131
  childrenBox.dataset.loaded = "1";
147
132
  toggle.textContent = "▸";
148
133
  });
149
134
  };
150
135
 
136
+ /**
137
+ * Datei-Klick-Handler: inline für ansehbare Formate, sonst Sprung ins
138
+ * OS-Datei-Manager-Fenster.
139
+ */
151
140
  var openFile = function () {
152
- if (!viewerEl) return;
153
- if (opts.pdfInline && isViewable(item.name)) {
154
- var url =
155
- "/sambafile?path=" +
156
- encodeURIComponent(full) +
157
- "&disposition=inline";
158
- var isImg = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(item.name);
159
- viewerEl.innerHTML = "";
160
- var header = h(
141
+ if (!viewerElement) return;
142
+ if (opts.pdfInline && C.isViewable(item.name)) {
143
+ var streamUrl =
144
+ "/sambafile?path=" + encodeURIComponent(fullPath) + "&disposition=inline";
145
+ var isImage = /\.(png|jpe?g|gif|webp|svg|bmp)$/i.test(item.name);
146
+ viewerElement.innerHTML = "";
147
+ var header = element(
161
148
  "div",
162
149
  { class: "samba-viewer-header d-flex align-items-center mb-2" },
163
150
  [
164
- h("strong", { text: item.name }, []),
165
- h(
151
+ element("strong", { text: item.name }, []),
152
+ element(
166
153
  "a",
167
154
  {
168
- href: url,
155
+ href: streamUrl,
169
156
  target: "_blank",
170
157
  class: "btn btn-sm btn-outline-secondary ms-auto me-2",
171
- text: "Open in new tab",
158
+ text: C.t("tree.open_new_tab"),
172
159
  },
173
160
  []
174
161
  ),
175
- h(
162
+ element(
176
163
  "a",
177
164
  {
178
165
  href:
179
166
  "/sambafile?path=" +
180
- encodeURIComponent(full) +
167
+ encodeURIComponent(fullPath) +
181
168
  "&disposition=attachment",
182
169
  class: "btn btn-sm btn-outline-secondary me-2",
183
- text: "Download",
170
+ text: C.t("fm.download"),
184
171
  },
185
172
  []
186
173
  ),
187
174
  opts.exposeSmbLink
188
- ? h(
175
+ ? element(
189
176
  "a",
190
177
  {
191
- href: "/sambalink?path=" + encodeURIComponent(full),
178
+ href: "/sambalink?path=" + encodeURIComponent(fullPath),
192
179
  target: "_blank",
193
180
  class: "btn btn-sm btn-outline-primary",
194
- text: "Open in file manager",
181
+ text: C.t("tree.open_in_fm"),
195
182
  },
196
183
  []
197
184
  )
198
185
  : null,
199
186
  ]
200
187
  );
201
- viewerEl.appendChild(header);
202
- if (isImg) {
203
- viewerEl.appendChild(
204
- h(
188
+ viewerElement.appendChild(header);
189
+ if (isImage) {
190
+ viewerElement.appendChild(
191
+ element(
205
192
  "img",
206
193
  {
207
- src: url,
194
+ src: streamUrl,
208
195
  style:
209
196
  "max-width:100%;max-height:70vh;border:1px solid #dee2e6;border-radius:4px;",
210
197
  },
@@ -212,11 +199,11 @@
212
199
  )
213
200
  );
214
201
  } else {
215
- viewerEl.appendChild(
216
- h(
202
+ viewerElement.appendChild(
203
+ element(
217
204
  "iframe",
218
205
  {
219
- src: url,
206
+ src: streamUrl,
220
207
  style:
221
208
  "width:100%;height:70vh;border:1px solid #dee2e6;border-radius:4px;",
222
209
  },
@@ -225,66 +212,68 @@
225
212
  );
226
213
  }
227
214
  } else {
228
- // Not inline-viewable fall back to the external link page.
229
- window.open(
230
- "/sambalink?path=" + encodeURIComponent(full),
231
- "_blank"
232
- );
215
+ // Nicht inline-fähig: Klassischer Sprung in die smb://-Zwischenseite.
216
+ window.open("/sambalink?path=" + encodeURIComponent(fullPath), "_blank");
233
217
  }
234
218
  };
235
219
 
236
220
  label.addEventListener("click", function () {
237
- if (item.isDir) openDir();
221
+ if (item.isDir) toggleDirectory();
238
222
  else openFile();
239
223
  });
240
224
  toggle.addEventListener("click", function () {
241
- if (item.isDir) openDir();
225
+ if (item.isDir) toggleDirectory();
242
226
  });
243
227
 
244
228
  var lineChildren = [toggle, label, meta];
245
- if (externalBtn) lineChildren.push(externalBtn);
229
+ if (externalButton) lineChildren.push(externalButton);
246
230
 
247
- var li = h("li", { class: "samba-tree-item" }, [
248
- h("div", { class: "samba-tree-line d-flex align-items-center" }, lineChildren),
231
+ var lineItem = element("li", { class: "samba-tree-item" }, [
232
+ element("div", { class: "samba-tree-line d-flex align-items-center" }, lineChildren),
249
233
  childrenBox,
250
234
  ]);
251
235
  childrenBox.style.display = "none";
252
- ul.appendChild(li);
236
+ list.appendChild(lineItem);
253
237
  });
254
238
 
255
239
  if (!items.length) {
256
240
  container.appendChild(
257
- h(
241
+ element(
258
242
  "div",
259
- { class: "text-muted small fst-italic p-2", text: "(empty)" },
243
+ { class: "text-muted small fst-italic p-2", text: C.t("ui.empty_short") },
260
244
  []
261
245
  )
262
246
  );
263
247
  } else {
264
- container.appendChild(ul);
248
+ container.appendChild(list);
265
249
  }
266
250
  }
267
251
 
268
- function mount(elId) {
269
- var el = document.getElementById(elId);
270
- if (!el || el.dataset.mounted === "1") return;
271
- el.dataset.mounted = "1";
272
- var viewer = document.getElementById(elId + "-viewer");
252
+ /**
253
+ * Einstiegspunkt vom View-Shell aufgerufen.
254
+ * `elementId` bezeichnet das Baum-Root-`<div>`; ein optionales
255
+ * `elementId + "-viewer"` wird als Inline-Anzeige verwendet.
256
+ */
257
+ function mount(elementId) {
258
+ var root = document.getElementById(elementId);
259
+ if (!root || root.dataset.mounted === "1") return;
260
+ root.dataset.mounted = "1";
261
+ var viewer = document.getElementById(elementId + "-viewer");
273
262
  var opts = {};
274
263
  try {
275
- opts = JSON.parse(el.getAttribute("data-opts") || "{}");
264
+ opts = JSON.parse(root.getAttribute("data-opts") || "{}");
276
265
  } catch (_) {
277
266
  opts = {};
278
267
  }
279
- el.innerHTML =
280
- '<div class="text-muted small p-2">Loading…</div>';
281
- fetchDir(opts.startPath || "", opts.showHidden)
268
+ root.innerHTML =
269
+ '<div class="text-muted small p-2">' + C.t("ui.loading") + "</div>";
270
+ fetchDirectory(opts.startPath || "", opts.showHidden)
282
271
  .then(function (data) {
283
- renderList(el, opts.startPath || "", data.items || [], opts, viewer);
272
+ renderLevel(root, opts.startPath || "", data.items || [], opts, viewer);
284
273
  })
285
- .catch(function (e) {
286
- el.innerHTML =
287
- '<div class="alert alert-danger">Samba: ' + e.message + "</div>";
274
+ .catch(function (err) {
275
+ root.innerHTML =
276
+ '<div class="alert alert-danger">' + C.t("tree.samba_prefix") + err.message + "</div>";
288
277
  });
289
278
  }
290
279
 
package/tree-view.js CHANGED
@@ -19,6 +19,7 @@ const Field = require("@saltcorn/data/models/field");
19
19
  const Table = require("@saltcorn/data/models/table");
20
20
 
21
21
  const { withClient, toSmbUrl, sanitizeRelativePath } = require("./smb-client");
22
+ const { catalogFor, resolveLocaleFromReq, tFor } = require("./i18n");
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Configuration workflow
@@ -41,30 +42,42 @@ const configuration_workflow = () =>
41
42
  .map((f) => f.name);
42
43
  return new Form({
43
44
  fields: [
45
+ new Field({
46
+ name: "view_base_path",
47
+ label: "View-Basispfad (relativ zum Plugin-Basispfad)",
48
+ type: "String",
49
+ sublabel:
50
+ "Optional. Statischer Pfad relativ zum Plugin-Basispfad, der f\u00fcr diese View gilt. " +
51
+ "Beispiel: Plugin-Basispfad = 'static', View-Basispfad = 'projekte/2026' \u2192 View listet 'static/projekte/2026'. " +
52
+ "Keine f\u00fchrenden/abschlie\u00dfenden Slashes n\u00f6tig. Traversal (\u201e..\u201c) wird abgelehnt.",
53
+ }),
44
54
  new Field({
45
55
  name: "mode",
46
- label: "Path mode",
56
+ label: "Row-Modus",
47
57
  type: "String",
48
58
  required: true,
49
59
  attributes: {
50
60
  options: ["static", "from_field"],
51
61
  },
52
62
  sublabel:
53
- "static = always show the plugin base_path; from_field = append a row field value",
63
+ "static = immer nur der View-Basispfad. from_field = h\u00e4nge einen Feldwert der aktuellen Zeile an " +
64
+ "(nur sinnvoll, wenn die View in einer Show-View eingebettet ist).",
65
+ default: "static",
54
66
  }),
55
67
  new Field({
56
68
  name: "path_field",
57
- label: "Row field with sub-path",
69
+ label: "Feld mit Unterpfad",
58
70
  type: "String",
59
71
  attributes: { options: stringFieldOptions },
60
72
  showIf: { mode: "from_field" },
61
73
  }),
62
74
  new Field({
63
75
  name: "extra_subpath",
64
- label: "Extra sub-path (appended)",
76
+ label: "Zus\u00e4tzlicher Suffix (nach dem Feldwert)",
65
77
  type: "String",
66
78
  sublabel:
67
- "Optional. Static suffix appended after the field value. Example: 'invoices'",
79
+ "Optional. Statischer Suffix, der nach dem Feldwert angeh\u00e4ngt wird. Beispiel: 'invoices'.",
80
+ showIf: { mode: "from_field" },
68
81
  }),
69
82
  new Field({
70
83
  name: "show_hidden",
@@ -104,17 +117,35 @@ async function get_state_fields(table_id, viewname, { mode, path_field }) {
104
117
  }
105
118
 
106
119
  /**
107
- * Given the view configuration + a Saltcorn row, compute the effective
108
- * relative path (below base_path) that the tree should start from.
120
+ * Compose the start path (relative to the plugin base_path) from three
121
+ * optional parts:
122
+ * 1. `view_base_path` — static, per-view prefix
123
+ * 2. row field value — only in "from_field" mode
124
+ * 3. `extra_subpath` — static suffix, only in "from_field" mode
125
+ *
126
+ * Empty parts are dropped and slash-trimmed. The final joined path is
127
+ * passed through sanitizeRelativePath so traversal / absolute paths /
128
+ * backslashes are rejected consistently.
109
129
  */
110
130
  function computeStartPath(configuration, row) {
111
131
  const parts = [];
132
+ if (configuration.view_base_path) {
133
+ parts.push(String(configuration.view_base_path));
134
+ }
112
135
  if (configuration.mode === "from_field" && configuration.path_field && row) {
113
136
  const v = row[configuration.path_field];
114
137
  if (v) parts.push(String(v));
115
138
  }
116
- if (configuration.extra_subpath) parts.push(String(configuration.extra_subpath));
117
- const joined = parts.join("/");
139
+ if (
140
+ configuration.mode === "from_field" &&
141
+ configuration.extra_subpath
142
+ ) {
143
+ parts.push(String(configuration.extra_subpath));
144
+ }
145
+ const joined = parts
146
+ .map((p) => p.replace(/^\/+|\/+$/g, ""))
147
+ .filter(Boolean)
148
+ .join("/");
118
149
  // sanitizeRelativePath throws on traversal – we let the caller handle it.
119
150
  return sanitizeRelativePath(joined);
120
151
  }
@@ -123,37 +154,59 @@ function computeStartPath(configuration, row) {
123
154
  // Render – client-side JS is served from /public/samba-tree.js
124
155
  // ---------------------------------------------------------------------------
125
156
 
126
- function renderShell(viewname, startPath, configuration) {
157
+ /**
158
+ * Escaped-Serialisierung des i18n-Katalogs, damit ein enthaltenes `</script>`
159
+ * im \u00dcbersetzungstext den HTML-Parser nicht vorzeitig beendet.
160
+ */
161
+ function serialiseCatalog(catalog) {
162
+ return JSON.stringify(catalog || {}).replace(/</g, "\\u003c");
163
+ }
164
+
165
+ function renderShell(viewname, startPath, configuration, locale, catalog) {
127
166
  const treeId = `samba-tree-${Math.random().toString(36).slice(2, 10)}`;
128
167
  const pluginVersion = configuration.__pluginVersion || "0.2.0";
168
+ const pathLabel = (catalog && catalog["ui.path"]) || "Path:";
129
169
  const opts = {
130
170
  viewname,
131
171
  startPath,
132
172
  pdfInline: !!configuration.pdf_inline,
133
173
  exposeSmbLink: configuration.expose_smb_link !== false,
134
174
  showHidden: !!configuration.show_hidden,
175
+ locale: locale || "en",
135
176
  };
177
+ const catalogJson = serialiseCatalog(catalog);
136
178
  return (
137
179
  div(
138
180
  { class: "samba-tree-container card p-2" },
139
181
  div(
140
182
  { class: "samba-tree-toolbar d-flex align-items-center mb-2" },
141
- span({ class: "text-muted small me-2" }, "Path:"),
183
+ span({ class: "text-muted small me-2" }, pathLabel),
142
184
  span({ class: "samba-tree-breadcrumb small fw-bold" }, startPath || "/")
143
185
  ),
144
186
  div({ id: treeId, class: "samba-tree", "data-opts": JSON.stringify(opts) }),
145
187
  div({ id: treeId + "-viewer", class: "samba-viewer mt-3" })
146
188
  ) +
147
189
  script(
190
+ // Erst samba-common.js (i18n + Utilities) nachladen, Katalog setzen,
191
+ // danach die eigentliche samba-tree.js booten.
148
192
  `(function(){
149
- function boot(){window.saltcornSambaMount && window.saltcornSambaMount(${JSON.stringify(
193
+ var CATALOG=${catalogJson};
194
+ function applyCatalog(){ if(window.SambaCommon && window.SambaCommon.setCatalog) window.SambaCommon.setCatalog(CATALOG); }
195
+ function mount(){window.saltcornSambaMount && window.saltcornSambaMount(${JSON.stringify(
150
196
  treeId
151
197
  )});}
152
- if(window.saltcornSambaMount){boot();return;}
153
- var s=document.createElement('script');
154
- s.src='/plugins/public/saltcorn-samba@${pluginVersion}/samba-tree.js';
155
- s.onload=boot;
156
- document.head.appendChild(s);
198
+ function loadTree(){
199
+ if(window.saltcornSambaMount){ mount(); return; }
200
+ var s=document.createElement('script');
201
+ s.src='/plugins/public/saltcorn-samba@${pluginVersion}/samba-tree.js';
202
+ s.onload=mount;
203
+ document.head.appendChild(s);
204
+ }
205
+ if(window.SambaCommon){ applyCatalog(); loadTree(); return; }
206
+ var c=document.createElement('script');
207
+ c.src='/plugins/public/saltcorn-samba@${pluginVersion}/samba-common.js';
208
+ c.onload=function(){ applyCatalog(); loadTree(); };
209
+ document.head.appendChild(c);
157
210
  })();`
158
211
  )
159
212
  );
@@ -163,6 +216,16 @@ function renderShell(viewname, startPath, configuration) {
163
216
  // View: run (single row) and runMany
164
217
  // ---------------------------------------------------------------------------
165
218
 
219
+ /**
220
+ * Bestimmt Locale und Katalog aus dem Saltcorn-Request. `resolveLocaleFromReq`
221
+ * f\u00e4llt bei fehlendem `req` auf "en" zur\u00fcck – kein Crash, kein leeres UI.
222
+ */
223
+ function resolveLocaleAndCatalog(extra) {
224
+ const req = extra && extra.req;
225
+ const locale = resolveLocaleFromReq(req);
226
+ return { locale, catalog: catalogFor(locale) };
227
+ }
228
+
166
229
  async function run(table_id, viewname, configuration, state, extra) {
167
230
  // "state" typically contains the primary key of the row when the view is
168
231
  // opened directly. If we are embedded in a Show view, `extra.row` is set.
@@ -177,13 +240,16 @@ async function run(table_id, viewname, configuration, state, extra) {
177
240
  try {
178
241
  startPath = computeStartPath(configuration, row || {});
179
242
  } catch (e) {
180
- return div({ class: "alert alert-danger" }, "Samba tree: " + e.message);
243
+ const _t = tFor(resolveLocaleFromReq(extra && extra.req));
244
+ return div({ class: "alert alert-danger" }, _t("tree.samba_prefix") + e.message);
181
245
  }
182
- return renderShell(viewname, startPath, configuration);
246
+ const { locale, catalog } = resolveLocaleAndCatalog(extra);
247
+ return renderShell(viewname, startPath, configuration, locale, catalog);
183
248
  }
184
249
 
185
250
  async function runMany(table_id, viewname, configuration, state, extra) {
186
251
  const rows = extra && extra.rows ? extra.rows : [];
252
+ const { locale, catalog } = resolveLocaleAndCatalog(extra);
187
253
  return rows.map((row) => ({
188
254
  html: renderShell(
189
255
  viewname,
@@ -194,7 +260,9 @@ async function runMany(table_id, viewname, configuration, state, extra) {
194
260
  return "";
195
261
  }
196
262
  })(),
197
- configuration
263
+ configuration,
264
+ locale,
265
+ catalog
198
266
  ),
199
267
  row,
200
268
  }));