tempest-express-sdk 0.26.0 → 0.28.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/index.cjs CHANGED
@@ -9461,6 +9461,23 @@ function partitionTotal(partition) {
9461
9461
  return partition.segments.reduce((total, segment) => total + segment.value, 0);
9462
9462
  }
9463
9463
 
9464
+ // src/admin/inlines.ts
9465
+ function adminInline(options) {
9466
+ const slug = options.model.tablename;
9467
+ if (typeof slug !== "string" || slug === "") {
9468
+ throw new Error("adminInline requires a concrete model with a tablename");
9469
+ }
9470
+ return {
9471
+ model: options.model,
9472
+ slug,
9473
+ fkField: options.fkField,
9474
+ listDisplay: options.listDisplay === void 0 ? null : [...options.listDisplay],
9475
+ label: options.label ?? null,
9476
+ editable: options.editable ?? false,
9477
+ canDelete: options.canDelete ?? false
9478
+ };
9479
+ }
9480
+
9464
9481
  // src/admin/lenses.ts
9465
9482
  function slugify(name) {
9466
9483
  return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
@@ -9498,6 +9515,85 @@ function adminAction(options, handler) {
9498
9515
  };
9499
9516
  }
9500
9517
 
9518
+ // src/admin/multipart.ts
9519
+ var BUSBOY_HINT = "The admin's upload and CSV-import screens need the optional peer `busboy`. Install it with: npm install busboy";
9520
+ async function loadBusboy() {
9521
+ try {
9522
+ const module = await import('busboy');
9523
+ return module.default ?? module;
9524
+ } catch {
9525
+ throw new Error(BUSBOY_HINT);
9526
+ }
9527
+ }
9528
+ var MultipartLimitError = class extends Error {
9529
+ /**
9530
+ * @param message - The operator-facing explanation.
9531
+ */
9532
+ constructor(message) {
9533
+ super(message);
9534
+ this.name = "MultipartLimitError";
9535
+ }
9536
+ };
9537
+ async function parseMultipart(req, options = {}) {
9538
+ const maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
9539
+ const maxFiles = options.maxFiles ?? 10;
9540
+ const busboy = await loadBusboy();
9541
+ return await new Promise((resolve, reject) => {
9542
+ const fields = {};
9543
+ const files = [];
9544
+ let settled = false;
9545
+ const fail = (error) => {
9546
+ if (settled) return;
9547
+ settled = true;
9548
+ reject(error);
9549
+ };
9550
+ const parser = busboy({
9551
+ headers: req.headers,
9552
+ limits: { fileSize: maxFileBytes, files: maxFiles }
9553
+ });
9554
+ parser.on("field", ((name, value) => {
9555
+ fields[name] = value;
9556
+ }));
9557
+ parser.on("file", ((name, stream, info) => {
9558
+ const chunks = [];
9559
+ stream.on("data", ((chunk) => chunks.push(chunk)));
9560
+ stream.on("limit", () => {
9561
+ fail(
9562
+ new MultipartLimitError(
9563
+ `The file is larger than the ${Math.floor(maxFileBytes / 1024 / 1024)} MB limit.`
9564
+ )
9565
+ );
9566
+ });
9567
+ stream.on("end", () => {
9568
+ const data = Buffer.concat(chunks);
9569
+ const filename = (info.filename ?? "").split(/[/\\]/).pop() ?? "";
9570
+ if (filename === "" || data.length === 0) return;
9571
+ files.push({
9572
+ field: name,
9573
+ filename,
9574
+ contentType: info.mimeType ?? "application/octet-stream",
9575
+ data
9576
+ });
9577
+ });
9578
+ }));
9579
+ parser.on("filesLimit", () => {
9580
+ fail(new MultipartLimitError(`At most ${maxFiles} files can be uploaded at once.`));
9581
+ });
9582
+ parser.on("error", ((error) => {
9583
+ fail(error instanceof Error ? error : new Error(String(error)));
9584
+ }));
9585
+ parser.on("close", () => {
9586
+ if (settled) return;
9587
+ settled = true;
9588
+ resolve({ fields, files });
9589
+ });
9590
+ req.pipe(parser);
9591
+ });
9592
+ }
9593
+ function isMultipart(req) {
9594
+ return (req.header("content-type") ?? "").toLowerCase().startsWith("multipart/form-data");
9595
+ }
9596
+
9501
9597
  // src/admin/columns.ts
9502
9598
  function humanizeField(name) {
9503
9599
  return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
@@ -9591,6 +9687,8 @@ var NEVER_EDITABLE = [
9591
9687
  "id",
9592
9688
  "createdAt",
9593
9689
  "updatedAt",
9690
+ "createdBy",
9691
+ "updatedBy",
9594
9692
  "hashedPassword"
9595
9693
  ];
9596
9694
  var NEVER_LISTED = ["hashedPassword"];
@@ -9627,6 +9725,16 @@ var AdminModel = class {
9627
9725
  auditModel;
9628
9726
  /** Saved list-view presets, in declaration order. */
9629
9727
  lenses;
9728
+ /** Columns rendered as file inputs. */
9729
+ uploadFields;
9730
+ /** Backend persisting uploaded files, or `null`. */
9731
+ uploadStorage;
9732
+ /** Whether the CSV import page is exposed. */
9733
+ canImport;
9734
+ /** Foreign-key columns rendered as a typed search box. */
9735
+ autocompleteFields;
9736
+ /** Related child models listed on the detail view. */
9737
+ inlines;
9630
9738
  actions = /* @__PURE__ */ new Map();
9631
9739
  slugOverride;
9632
9740
  listDisplayOverride;
@@ -9654,6 +9762,16 @@ var AdminModel = class {
9654
9762
  this.canDelete = options.canDelete ?? true;
9655
9763
  this.auditModel = options.auditModel ?? null;
9656
9764
  this.lenses = [...options.lenses ?? []];
9765
+ this.uploadFields = [...options.uploadFields ?? []];
9766
+ this.uploadStorage = options.uploadStorage ?? null;
9767
+ this.canImport = options.canImport ?? false;
9768
+ this.autocompleteFields = [...options.autocompleteFields ?? []];
9769
+ this.inlines = [...options.inlines ?? []];
9770
+ if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9771
+ throw new Error(
9772
+ `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
9773
+ );
9774
+ }
9657
9775
  for (const action of options.actions ?? []) {
9658
9776
  if (this.actions.has(action.name)) {
9659
9777
  throw new Error(
@@ -9668,7 +9786,9 @@ var AdminModel = class {
9668
9786
  ["listFilter", this.listFilter],
9669
9787
  ["searchFields", this.searchFields],
9670
9788
  ["readonlyFields", this.readonlyFields],
9671
- ["identityField", [this.identityField]]
9789
+ ["identityField", [this.identityField]],
9790
+ ["uploadFields", this.uploadFields],
9791
+ ["autocompleteFields", this.autocompleteFields]
9672
9792
  ]) {
9673
9793
  for (const name of names) {
9674
9794
  if (!known.has(name)) {
@@ -9850,11 +9970,15 @@ function buildFormFields(admin, options = {}) {
9850
9970
  const values = options.values ?? {};
9851
9971
  const errors = options.errors ?? {};
9852
9972
  const foreignKeys = options.foreignKeyOptions ?? {};
9973
+ const autocompleteUrls = options.autocompleteUrls ?? {};
9974
+ const autocompleteLabels = options.autocompleteLabels ?? {};
9975
+ const uploads = new Set(admin.uploadFields);
9853
9976
  return admin.editableFieldNames().flatMap((name) => {
9854
9977
  const column6 = columns[name];
9855
9978
  if (column6 === void 0) return [];
9856
9979
  const related = foreignKeys[name];
9857
- const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9980
+ const autocompleteUrl = autocompleteUrls[name];
9981
+ const spec = uploads.has(name) ? { widget: "file", step: null, options: [] } : autocompleteUrl !== void 0 ? { widget: "autocomplete", step: null, options: [] } : related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9858
9982
  const raw = name in values ? values[name] : literalDefault(column6);
9859
9983
  return [
9860
9984
  {
@@ -9866,7 +9990,9 @@ function buildFormFields(admin, options = {}) {
9866
9990
  checked: spec.widget === "checkbox" && toBoolean(raw),
9867
9991
  step: spec.step,
9868
9992
  options: spec.options,
9869
- error: errors[name] ?? null
9993
+ error: errors[name] ?? null,
9994
+ autocompleteUrl: autocompleteUrl ?? null,
9995
+ displayLabel: autocompleteLabels[name] ?? ""
9870
9996
  }
9871
9997
  ];
9872
9998
  });
@@ -9914,13 +10040,17 @@ function coerceValue(column6, widget, raw) {
9914
10040
  return raw;
9915
10041
  }
9916
10042
  }
9917
- function parseFormBody(admin, body) {
10043
+ function parseFormBody(admin, body, options = {}) {
9918
10044
  const columns = adminColumns(admin.model);
9919
10045
  const data = {};
9920
10046
  const errors = {};
10047
+ const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
10048
+ const only = options.only === void 0 ? null : new Set(options.only);
9921
10049
  for (const name of admin.editableFieldNames()) {
10050
+ if (only !== null && !only.has(name)) continue;
9922
10051
  const column6 = columns[name];
9923
10052
  if (column6 === void 0) continue;
10053
+ if (uploads.has(name)) continue;
9924
10054
  const { widget } = widgetForColumn(column6);
9925
10055
  if (widget === "checkbox") {
9926
10056
  data[name] = toBoolean(body[name]);
@@ -12206,6 +12336,7 @@ function renderListPage(context, view) {
12206
12336
  </form>
12207
12337
  <div class="tempest-admin-list__actions">
12208
12338
  ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
12339
+ ${view.importUrl !== null ? `<a href="${escapeHtml(view.importUrl)}">Import CSV</a>` : ""}
12209
12340
  <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
12210
12341
  <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12211
12342
  </div>
@@ -12250,10 +12381,97 @@ function renderDetailPage(context, view) {
12250
12381
  </div>
12251
12382
  </header>
12252
12383
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12384
+ ${view.inlines.map((inline) => renderInline(inline, context.session?.csrfToken ?? "", view.inlineError)).join("")}
12253
12385
  ${auditPanel}
12254
12386
  </section>`;
12255
12387
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12256
12388
  }
12389
+ function renderInlineCell(field) {
12390
+ const required = field.required ? " required" : "";
12391
+ const name = escapeHtml(field.name);
12392
+ const value = escapeHtml(field.value);
12393
+ let control;
12394
+ switch (field.widget) {
12395
+ case "checkbox":
12396
+ control = `<input type="checkbox" name="${name}" value="true"${field.checked ? " checked" : ""}>`;
12397
+ break;
12398
+ case "textarea":
12399
+ case "json":
12400
+ control = `<textarea name="${name}" rows="2"${required}>${value}</textarea>`;
12401
+ break;
12402
+ case "select": {
12403
+ const blank = field.required ? "" : '<option value="">\u2014 none \u2014</option>';
12404
+ const options = field.options.map(
12405
+ (option) => `<option value="${escapeHtml(option.value)}"${option.value === field.value ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12406
+ ).join("");
12407
+ control = `<select name="${name}"${required}>${blank}${options}</select>`;
12408
+ break;
12409
+ }
12410
+ case "number":
12411
+ control = `<input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}>`;
12412
+ break;
12413
+ case "datetime":
12414
+ control = `<input type="datetime-local" name="${name}" value="${value}"${required}>`;
12415
+ break;
12416
+ case "date":
12417
+ control = `<input type="date" name="${name}" value="${value}"${required}>`;
12418
+ break;
12419
+ case "time":
12420
+ control = `<input type="time" name="${name}" value="${value}"${required}>`;
12421
+ break;
12422
+ default:
12423
+ control = `<input type="text" name="${name}" value="${value}"${required}>`;
12424
+ }
12425
+ const error = field.error !== null ? `<small class="tempest-admin-form__field-error">${escapeHtml(field.error)}</small>` : "";
12426
+ return `${control}${error}`;
12427
+ }
12428
+ function renderInline(inline, csrfToken, error) {
12429
+ const heading = `<header class="tempest-admin-inline__header">
12430
+ <h2>${escapeHtml(inline.label)}${inline.total > 0 ? ` <span class="tempest-admin-inline__count">(${escapeHtml(inline.total)})</span>` : ""}</h2>
12431
+ ${inline.addUrl !== null ? `<a class="tempest-admin-btn" href="${escapeHtml(inline.addUrl)}">Add</a>` : ""}
12432
+ </header>`;
12433
+ const more = inline.truncated ? `<p class="tempest-admin-inline__more"><em>Showing the first ${escapeHtml(inline.rows.length)} of ${escapeHtml(inline.total)}.</em></p>` : "";
12434
+ if (!inline.editable) {
12435
+ if (inline.rows.length === 0) {
12436
+ return `<section class="tempest-admin-inline">${heading}<p><em>No related records.</em></p></section>`;
12437
+ }
12438
+ const head2 = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}<th></th></tr>`;
12439
+ const body = inline.rows.map(
12440
+ (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td>${row.url === null ? "" : `<a href="${escapeHtml(row.url)}">View</a>`}</td></tr>`
12441
+ ).join("");
12442
+ return `<section class="tempest-admin-inline">
12443
+ ${heading}
12444
+ <div class="tempest-admin-inline__scroll">
12445
+ <table class="tempest-admin-inline__table">
12446
+ <thead>${head2}</thead>
12447
+ <tbody>${body}</tbody>
12448
+ </table>
12449
+ </div>
12450
+ ${more}
12451
+ </section>`;
12452
+ }
12453
+ const head = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12454
+ const renderRow = (row, isNew) => `<tr${isNew ? ' class="tempest-admin-inline__new"' : ""}>${row.fields.map((field) => `<td>${renderInlineCell(field)}</td>`).join("")}${inline.canDelete ? `<td class="tempest-admin-inline__del">${isNew ? "" : `<input type="checkbox" name="row.${escapeHtml(row.key)}.__delete" value="true">`}</td>` : ""}</tr>`;
12455
+ const rows = inline.rows.map((row) => renderRow(row, false)).join("");
12456
+ const blank = inline.newRow === null ? "" : renderRow(inline.newRow, true);
12457
+ return `<section class="tempest-admin-inline">
12458
+ ${heading}
12459
+ ${error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(error)}</p>` : ""}
12460
+ <form method="post" action="${escapeHtml(inline.formAction)}" class="tempest-admin-inline__form">
12461
+ <input type="hidden" name="csrf_token" value="${escapeHtml(csrfToken)}">
12462
+ <div class="tempest-admin-inline__scroll">
12463
+ <table class="tempest-admin-inline__table">
12464
+ <thead>${head}</thead>
12465
+ <tbody>${rows}${blank}</tbody>
12466
+ </table>
12467
+ </div>
12468
+ <div class="tempest-admin-form__actions">
12469
+ <button type="submit">Save ${escapeHtml(inline.label)}</button>
12470
+ </div>
12471
+ </form>
12472
+ ${more}
12473
+ </section>`;
12474
+ }
12257
12475
  function renderAuditPanel(audit) {
12258
12476
  const rows = audit.fields.map(
12259
12477
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
@@ -12305,6 +12523,18 @@ function renderFormField(field) {
12305
12523
  control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
12306
12524
  break;
12307
12525
  }
12526
+ case "file":
12527
+ control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="file" name="${name}"${required}></label>${field.value === "" ? "" : `<small class="tempest-admin-form__hint">Current: ${value} \u2014 choose a file to replace it.</small>`}`;
12528
+ break;
12529
+ case "autocomplete":
12530
+ control = `<label><span>${label}${field.required ? " *" : ""}</span>
12531
+ <div class="tempest-admin-ac" data-ac data-ac-url="${escapeHtml(field.autocompleteUrl)}">
12532
+ <input type="text" class="tempest-admin-ac__search" value="${escapeHtml(field.displayLabel)}" placeholder="Search\u2026" autocomplete="off" data-ac-search>
12533
+ <input type="hidden" name="${name}" value="${value}"${required} data-ac-value>
12534
+ <ul class="tempest-admin-ac__results" data-ac-results hidden></ul>
12535
+ </div>
12536
+ </label>`;
12537
+ break;
12308
12538
  case "number":
12309
12539
  control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
12310
12540
  break;
@@ -12332,7 +12562,7 @@ function renderFormPage(context, view) {
12332
12562
  <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12333
12563
  </header>
12334
12564
  ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12335
- <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form">
12565
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form"${view.fields.some((field) => field.widget === "file") ? ' enctype="multipart/form-data"' : ""}>
12336
12566
  <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12337
12567
  ${view.fields.map(renderFormField).join("")}
12338
12568
  <div class="tempest-admin-form__actions">
@@ -12340,10 +12570,98 @@ function renderFormPage(context, view) {
12340
12570
  <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12341
12571
  </div>
12342
12572
  </form>
12573
+ ${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
12343
12574
  </section>`;
12344
12575
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12345
12576
  }
12577
+ function renderImportPage(context, view) {
12578
+ if (context.session === null) throw new Error("The import page requires a session");
12579
+ const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
12580
+ const failures = view.rowErrors.length > 0 ? `<table class="tempest-admin-import__errors"><thead><tr><th>Row</th><th>Problem</th></tr></thead><tbody>${view.rowErrors.map(
12581
+ (failure) => `<tr><td>${escapeHtml(failure.row)}</td><td>${escapeHtml(failure.message)}</td></tr>`
12582
+ ).join("")}</tbody></table>` : "";
12583
+ const body = `<section class="tempest-admin-import">
12584
+ <header class="tempest-admin-detail__header">
12585
+ <h1>Import ${escapeHtml(view.title)}</h1>
12586
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12587
+ </header>
12588
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12589
+ ${summary}
12590
+ ${failures}
12591
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form" enctype="multipart/form-data">
12592
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12593
+ <div class="tempest-admin-form__field">
12594
+ <label>
12595
+ <span>CSV file *</span>
12596
+ <input type="file" name="file" accept=".csv,text/csv" required>
12597
+ </label>
12598
+ <small class="tempest-admin-form__hint">
12599
+ UTF-8, comma-separated, with a header row. Recognised columns:
12600
+ <code>${escapeHtml(view.columns.join(", "))}</code>. Unknown columns are ignored.
12601
+ </small>
12602
+ </div>
12603
+ <div class="tempest-admin-form__actions">
12604
+ <button type="submit">Import</button>
12605
+ <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12606
+ </div>
12607
+ </form>
12608
+ </section>`;
12609
+ return renderLayout(context, `Import ${view.title} \xB7 ${context.site.title}`, body);
12610
+ }
12611
+ var AUTOCOMPLETE_SCRIPT = `<script>
12612
+ (function () {
12613
+ document.querySelectorAll("[data-ac]").forEach(function (box) {
12614
+ var search = box.querySelector("[data-ac-search]");
12615
+ var value = box.querySelector("[data-ac-value]");
12616
+ var results = box.querySelector("[data-ac-results]");
12617
+ var url = box.getAttribute("data-ac-url");
12618
+ var timer = null;
12619
+
12620
+ function close() {
12621
+ results.hidden = true;
12622
+ results.innerHTML = "";
12623
+ }
12624
+
12625
+ function pick(option) {
12626
+ value.value = option.value;
12627
+ search.value = option.label;
12628
+ close();
12629
+ }
12630
+
12631
+ function run() {
12632
+ var term = search.value.trim();
12633
+ fetch(url + "?q=" + encodeURIComponent(term), { credentials: "same-origin" })
12634
+ .then(function (response) { return response.ok ? response.json() : { options: [] }; })
12635
+ .then(function (payload) {
12636
+ results.innerHTML = "";
12637
+ (payload.options || []).forEach(function (option) {
12638
+ var item = document.createElement("li");
12639
+ item.textContent = option.label;
12640
+ item.setAttribute("role", "option");
12641
+ item.addEventListener("mousedown", function (event) {
12642
+ event.preventDefault();
12643
+ pick(option);
12644
+ });
12645
+ results.appendChild(item);
12646
+ });
12647
+ results.hidden = results.children.length === 0;
12648
+ })
12649
+ .catch(close);
12650
+ }
12651
+
12652
+ search.addEventListener("input", function () {
12653
+ value.value = "";
12654
+ window.clearTimeout(timer);
12655
+ timer = window.setTimeout(run, 250);
12656
+ });
12657
+ search.addEventListener("focus", run);
12658
+ search.addEventListener("blur", function () { window.setTimeout(close, 150); });
12659
+ });
12660
+ })();
12661
+ </script>`;
12346
12662
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12663
+ var INLINE_ROW_LIMIT = 50;
12664
+ var AUTOCOMPLETE_LIMIT = 20;
12347
12665
  var AUDIT_HISTORY_LIMIT = 50;
12348
12666
  var FK_OPTION_CAP = 1e3;
12349
12667
  var FLASH_MAX_LENGTH = 300;
@@ -12373,6 +12691,7 @@ function makeAdminRouter(site, options) {
12373
12691
  const theme = resolveAdminTheme(site.theme);
12374
12692
  const showMetrics = options.showMetrics ?? true;
12375
12693
  const exportMaxRows = options.exportMaxRows ?? 5e3;
12694
+ const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12376
12695
  const sessions = new AdminSessionStore({
12377
12696
  secret: options.secretKey,
12378
12697
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12630,7 +12949,8 @@ function makeAdminRouter(site, options) {
12630
12949
  mode: "create",
12631
12950
  title: admin.verboseName(),
12632
12951
  fields: buildFormFields(admin, {
12633
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12952
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
12953
+ autocompleteUrls: autocompleteUrlsFor(admin)
12634
12954
  }),
12635
12955
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12636
12956
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12650,10 +12970,28 @@ function makeAdminRouter(site, options) {
12650
12970
  html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12651
12971
  return;
12652
12972
  }
12653
- if (!checkCsrf(req, res, state)) return;
12654
- const body = req.body;
12973
+ let uploadError = null;
12974
+ let submission;
12975
+ try {
12976
+ submission = await readSubmission(req);
12977
+ } catch (error) {
12978
+ if (!(error instanceof MultipartLimitError)) throw error;
12979
+ submission = { fields: {}, files: [] };
12980
+ uploadError = error.message;
12981
+ }
12982
+ const body = submission.fields;
12983
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
12984
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12985
+ return;
12986
+ }
12655
12987
  const parsed = parseFormBody(admin, body);
12988
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
12656
12989
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12990
+ const autocompleteLabels = await autocompleteLabelsFor(
12991
+ admin,
12992
+ body,
12993
+ state.dbSession
12994
+ );
12657
12995
  const rerender = (error, status) => {
12658
12996
  html(
12659
12997
  res,
@@ -12663,7 +13001,9 @@ function makeAdminRouter(site, options) {
12663
13001
  fields: buildFormFields(admin, {
12664
13002
  values: body,
12665
13003
  errors: parsed.errors,
12666
- foreignKeyOptions
13004
+ foreignKeyOptions,
13005
+ autocompleteUrls: autocompleteUrlsFor(admin),
13006
+ autocompleteLabels
12667
13007
  }),
12668
13008
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12669
13009
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12672,6 +13012,10 @@ function makeAdminRouter(site, options) {
12672
13012
  status
12673
13013
  );
12674
13014
  };
13015
+ if (uploadError !== null) {
13016
+ rerender(uploadError, 400);
13017
+ return;
13018
+ }
12675
13019
  if (Object.keys(parsed.errors).length > 0) {
12676
13020
  rerender("Please fix the highlighted fields.", 400);
12677
13021
  return;
@@ -12791,6 +13135,129 @@ function makeAdminRouter(site, options) {
12791
13135
  );
12792
13136
  })
12793
13137
  );
13138
+ router.get(
13139
+ `${prefix}/m/:slug/import`,
13140
+ guarded(async (req, res) => {
13141
+ const state = await authenticate(req, res);
13142
+ if (state === null) return;
13143
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
13144
+ if (admin === null) return;
13145
+ if (!admin.canImport) {
13146
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13147
+ return;
13148
+ }
13149
+ html(res, renderImport(req, state, admin, null, null, []));
13150
+ })
13151
+ );
13152
+ router.post(
13153
+ `${prefix}/m/:slug/import`,
13154
+ guarded(async (req, res) => {
13155
+ const state = await authenticate(req, res);
13156
+ if (state === null) return;
13157
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
13158
+ if (admin === null) return;
13159
+ if (!admin.canImport) {
13160
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13161
+ return;
13162
+ }
13163
+ let submission;
13164
+ try {
13165
+ submission = await readSubmission(req);
13166
+ } catch (error) {
13167
+ if (!(error instanceof MultipartLimitError)) throw error;
13168
+ html(res, renderImport(req, state, admin, error.message, null, []), 400);
13169
+ return;
13170
+ }
13171
+ if (!csrfTokenMatches(state.session, submission.fields.csrf_token)) {
13172
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
13173
+ return;
13174
+ }
13175
+ const file = submission.files[0];
13176
+ if (file === void 0) {
13177
+ html(
13178
+ res,
13179
+ renderImport(req, state, admin, "Choose a CSV file to import.", null, []),
13180
+ 400
13181
+ );
13182
+ return;
13183
+ }
13184
+ let rows;
13185
+ try {
13186
+ rows = parseCsv(file.data.toString("utf8"));
13187
+ } catch (error) {
13188
+ const message = error instanceof Error ? error.message : "Could not read the file as UTF-8 CSV.";
13189
+ html(res, renderImport(req, state, admin, message, null, []), 400);
13190
+ return;
13191
+ }
13192
+ const repository = admin.repository(state.dbSession);
13193
+ const actorId = backend.principalId(state.principal);
13194
+ const rowErrors = [];
13195
+ let created = 0;
13196
+ for (const [index, row] of rows.entries()) {
13197
+ const parsed = parseFormBody(admin, row, { uploadsAsText: true });
13198
+ const failures = Object.entries(parsed.errors);
13199
+ if (failures.length > 0) {
13200
+ rowErrors.push({
13201
+ row: index + 2,
13202
+ message: failures.map(([field, error]) => `${field}: ${error}`).join("; ")
13203
+ });
13204
+ continue;
13205
+ }
13206
+ stampActor(admin, parsed.data, actorId, true);
13207
+ try {
13208
+ await repository.create(parsed.data);
13209
+ created += 1;
13210
+ } catch (error) {
13211
+ rowErrors.push({
13212
+ row: index + 2,
13213
+ message: error instanceof Error ? error.message : String(error)
13214
+ });
13215
+ }
13216
+ }
13217
+ html(res, renderImport(req, state, admin, null, created, rowErrors));
13218
+ })
13219
+ );
13220
+ router.get(
13221
+ `${prefix}/m/:slug/autocomplete/:field`,
13222
+ guarded(async (req, res) => {
13223
+ if (sessions.load(req) === null) {
13224
+ res.status(401).json({ options: [] });
13225
+ return;
13226
+ }
13227
+ const state = await authenticate(req, res);
13228
+ if (state === null) return;
13229
+ const admin = site.get(String(req.params.slug));
13230
+ const field = String(req.params.field);
13231
+ if (admin === null || !admin.autocompleteFields.includes(field) || !await allows(state.principal, admin, AdminPermission.VIEW)) {
13232
+ res.status(404).json({ options: [] });
13233
+ return;
13234
+ }
13235
+ const column6 = adminColumns(admin.model)[field];
13236
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13237
+ const referenced = table === null ? null : site.get(table);
13238
+ if (referenced === null) {
13239
+ res.json({ options: [] });
13240
+ return;
13241
+ }
13242
+ const term = queryString(req.query.q);
13243
+ const searchable = referenced.searchFields.filter((name) => {
13244
+ const target = adminColumns(referenced.model)[name];
13245
+ return target !== void 0 && isSearchableColumn(target);
13246
+ });
13247
+ const filters = term === "" || searchable.length === 0 ? void 0 : tempestDbJs.or(...searchable.map((name) => ({ [name]: { ilike: `%${term}%` } })));
13248
+ const page2 = await referenced.repository(state.dbSession).paginate({
13249
+ page: 1,
13250
+ pageSize: AUTOCOMPLETE_LIMIT,
13251
+ ...filters === void 0 ? {} : { filters }
13252
+ });
13253
+ res.json({
13254
+ options: page2.items.map((row) => ({
13255
+ value: String(row[referenced.identityField]),
13256
+ label: foreignKeyLabel(referenced, row)
13257
+ }))
13258
+ });
13259
+ })
13260
+ );
12794
13261
  router.get(
12795
13262
  `${prefix}/m/:slug/:identity`,
12796
13263
  guarded(async (req, res) => {
@@ -12810,6 +13277,8 @@ function makeAdminRouter(site, options) {
12810
13277
  title: admin.verboseName(),
12811
13278
  identity,
12812
13279
  audit: await buildAuditView(admin, row, state.dbSession),
13280
+ inlines: await buildInlines(admin, row, state, identity),
13281
+ inlineError: null,
12813
13282
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
12814
13283
  backUrl: `${prefix}/m/${admin.slug()}`,
12815
13284
  editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
@@ -12838,7 +13307,9 @@ function makeAdminRouter(site, options) {
12838
13307
  title: admin.verboseName(),
12839
13308
  fields: buildFormFields(admin, {
12840
13309
  values: row,
12841
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
13310
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
13311
+ autocompleteUrls: autocompleteUrlsFor(admin),
13312
+ autocompleteLabels: await autocompleteLabelsFor(admin, row, state.dbSession)
12842
13313
  }),
12843
13314
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12844
13315
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12859,10 +13330,28 @@ function makeAdminRouter(site, options) {
12859
13330
  html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12860
13331
  return;
12861
13332
  }
12862
- if (!checkCsrf(req, res, state)) return;
12863
- const body = req.body;
13333
+ let uploadError = null;
13334
+ let submission;
13335
+ try {
13336
+ submission = await readSubmission(req);
13337
+ } catch (error) {
13338
+ if (!(error instanceof MultipartLimitError)) throw error;
13339
+ submission = { fields: {}, files: [] };
13340
+ uploadError = error.message;
13341
+ }
13342
+ const body = submission.fields;
13343
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
13344
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
13345
+ return;
13346
+ }
12864
13347
  const parsed = parseFormBody(admin, body);
13348
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
12865
13349
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
13350
+ const autocompleteLabels = await autocompleteLabelsFor(
13351
+ admin,
13352
+ body,
13353
+ state.dbSession
13354
+ );
12866
13355
  const rerender = (error, status) => {
12867
13356
  html(
12868
13357
  res,
@@ -12872,7 +13361,9 @@ function makeAdminRouter(site, options) {
12872
13361
  fields: buildFormFields(admin, {
12873
13362
  values: body,
12874
13363
  errors: parsed.errors,
12875
- foreignKeyOptions
13364
+ foreignKeyOptions,
13365
+ autocompleteUrls: autocompleteUrlsFor(admin),
13366
+ autocompleteLabels
12876
13367
  }),
12877
13368
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12878
13369
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12881,6 +13372,10 @@ function makeAdminRouter(site, options) {
12881
13372
  status
12882
13373
  );
12883
13374
  };
13375
+ if (uploadError !== null) {
13376
+ rerender(uploadError, 400);
13377
+ return;
13378
+ }
12884
13379
  if (Object.keys(parsed.errors).length > 0) {
12885
13380
  rerender("Please fix the highlighted fields.", 400);
12886
13381
  return;
@@ -12904,6 +13399,103 @@ function makeAdminRouter(site, options) {
12904
13399
  res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
12905
13400
  })
12906
13401
  );
13402
+ router.post(
13403
+ `${prefix}/m/:slug/:identity/inlines/:child`,
13404
+ guarded(async (req, res) => {
13405
+ const state = await authenticate(req, res);
13406
+ if (state === null) return;
13407
+ const admin = await resolveAdmin(req, res, state);
13408
+ if (admin === null) return;
13409
+ if (!checkCsrf(req, res, state)) return;
13410
+ const childSlug = String(req.params.child);
13411
+ const inline = admin.inlines.find(
13412
+ (entry) => entry.slug === childSlug && entry.editable
13413
+ );
13414
+ const childAdmin = inline === void 0 ? null : site.get(childSlug);
13415
+ if (inline === void 0 || childAdmin === null || !await allows(state.principal, childAdmin, AdminPermission.EDIT)) {
13416
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13417
+ return;
13418
+ }
13419
+ const identity = String(req.params.identity);
13420
+ const parent = await findRow(admin, state.dbSession, identity);
13421
+ if (parent === null) {
13422
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13423
+ return;
13424
+ }
13425
+ const parentId = parent[admin.identityField];
13426
+ const { rows: grouped, deletions } = groupInlineSubmission(
13427
+ req.body
13428
+ );
13429
+ const names = inlineFieldNames(childAdmin, inline);
13430
+ const childRepo = childAdmin.repository(state.dbSession);
13431
+ const actorId = backend.principalId(state.principal);
13432
+ const canDelete = inline.canDelete && await allows(state.principal, childAdmin, AdminPermission.DELETE);
13433
+ const failed = [];
13434
+ let formError = null;
13435
+ const owned = async (key) => await childRepo.first({
13436
+ [childAdmin.identityField]: key,
13437
+ [inline.fkField]: parentId
13438
+ });
13439
+ for (const [key, values] of Object.entries(grouped)) {
13440
+ const isNew = key.startsWith("new");
13441
+ if (!isNew && canDelete && deletions.has(key)) {
13442
+ if (await owned(key) !== null) {
13443
+ await childRepo.delete({ [childAdmin.identityField]: key });
13444
+ }
13445
+ continue;
13446
+ }
13447
+ if (isNew && Object.values(values).every((value) => value.trim() === "")) {
13448
+ continue;
13449
+ }
13450
+ const parsed = parseFormBody(childAdmin, values, { only: names });
13451
+ if (Object.keys(parsed.errors).length > 0) {
13452
+ failed.push({ key, values, errors: parsed.errors });
13453
+ continue;
13454
+ }
13455
+ try {
13456
+ if (isNew) {
13457
+ stampActor(childAdmin, parsed.data, actorId, true);
13458
+ await childRepo.create({
13459
+ ...parsed.data,
13460
+ [inline.fkField]: parentId
13461
+ });
13462
+ } else {
13463
+ if (await owned(key) === null) continue;
13464
+ stampActor(childAdmin, parsed.data, actorId, false);
13465
+ await childRepo.update(
13466
+ { [childAdmin.identityField]: key },
13467
+ parsed.data
13468
+ );
13469
+ }
13470
+ } catch (error) {
13471
+ formError = describeWriteFailure(childAdmin, error);
13472
+ failed.push({ key, values, errors: {} });
13473
+ }
13474
+ }
13475
+ if (failed.length > 0) {
13476
+ const fresh = await findRow(admin, state.dbSession, identity) ?? parent;
13477
+ html(
13478
+ res,
13479
+ renderDetailPage(context(req, state.session, state.visible), {
13480
+ title: admin.verboseName(),
13481
+ identity,
13482
+ audit: await buildAuditView(admin, fresh, state.dbSession),
13483
+ inlines: await buildInlines(admin, fresh, state, identity, {
13484
+ [childSlug]: failed
13485
+ }),
13486
+ inlineError: formError ?? "Some inline rows could not be saved.",
13487
+ fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(fresh[name]) })),
13488
+ backUrl: `${prefix}/m/${admin.slug()}`,
13489
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
13490
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
13491
+ }),
13492
+ 400
13493
+ );
13494
+ return;
13495
+ }
13496
+ res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13497
+ })
13498
+ );
12907
13499
  router.post(
12908
13500
  `${prefix}/m/:slug/:identity/delete`,
12909
13501
  guarded(async (req, res) => {
@@ -12925,6 +13517,89 @@ function makeAdminRouter(site, options) {
12925
13517
  const principal = await backend.loadPrincipal(dbSession, String(actor));
12926
13518
  return principal === null ? String(actor) : backend.displayName(principal);
12927
13519
  }
13520
+ async function buildInlines(admin, parent, state, identity, overrides = {}) {
13521
+ const parentId = parent[admin.identityField];
13522
+ const blocks = [];
13523
+ for (const inline of admin.inlines) {
13524
+ const childAdmin = site.get(inline.slug);
13525
+ const repository = childAdmin === null ? new tempestDbJs.BaseRepository(inline.model, state.dbSession) : childAdmin.repository(state.dbSession);
13526
+ const children = await repository.list({
13527
+ [inline.fkField]: parentId
13528
+ });
13529
+ const columns = inline.listDisplay ?? childAdmin?.listDisplayNames() ?? Object.keys(adminColumns(inline.model));
13530
+ const label = inline.label ?? childAdmin?.verboseNamePlural() ?? humanizeField(inline.slug);
13531
+ const visible = children.slice(0, INLINE_ROW_LIMIT);
13532
+ const editable = inline.editable && childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.EDIT);
13533
+ const addUrl = childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.CREATE) ? `${prefix}/m/${inline.slug}/new` : null;
13534
+ if (!editable || childAdmin === null) {
13535
+ blocks.push({
13536
+ label,
13537
+ total: children.length,
13538
+ columns,
13539
+ editable: false,
13540
+ canDelete: false,
13541
+ addUrl,
13542
+ formAction: "",
13543
+ rows: visible.map((child) => ({
13544
+ key: String(child[childAdmin?.identityField ?? "id"]),
13545
+ cells: columns.map((column6) => formatCellValue(child[column6])),
13546
+ fields: [],
13547
+ url: childAdmin === null ? null : `${prefix}/m/${inline.slug}/${String(child[childAdmin.identityField])}`
13548
+ })),
13549
+ newRow: null,
13550
+ truncated: children.length > visible.length
13551
+ });
13552
+ continue;
13553
+ }
13554
+ const names = inlineFieldNames(childAdmin, inline);
13555
+ const submitted = overrides[inline.slug];
13556
+ const rows = [];
13557
+ if (submitted !== void 0) {
13558
+ for (const entry of submitted) {
13559
+ rows.push({
13560
+ key: entry.key,
13561
+ cells: [],
13562
+ fields: inlineFields(
13563
+ childAdmin,
13564
+ names,
13565
+ entry.key,
13566
+ entry.values,
13567
+ entry.errors
13568
+ ),
13569
+ url: null
13570
+ });
13571
+ }
13572
+ } else {
13573
+ for (const child of visible) {
13574
+ const key = String(child[childAdmin.identityField]);
13575
+ rows.push({
13576
+ key,
13577
+ cells: [],
13578
+ fields: inlineFields(childAdmin, names, key, child, {}),
13579
+ url: `${prefix}/m/${inline.slug}/${key}`
13580
+ });
13581
+ }
13582
+ }
13583
+ blocks.push({
13584
+ label,
13585
+ total: children.length,
13586
+ columns: names.map(humanizeField),
13587
+ editable: true,
13588
+ canDelete: inline.canDelete && childAdmin.canDelete,
13589
+ addUrl,
13590
+ formAction: `${prefix}/m/${admin.slug()}/${identity}/inlines/${inline.slug}`,
13591
+ rows,
13592
+ newRow: {
13593
+ key: "new1",
13594
+ cells: [],
13595
+ fields: inlineFields(childAdmin, names, "new1", {}, {}),
13596
+ url: null
13597
+ },
13598
+ truncated: children.length > visible.length
13599
+ });
13600
+ }
13601
+ return blocks;
13602
+ }
12928
13603
  async function buildAuditView(admin, row, dbSession) {
12929
13604
  const fields = [];
12930
13605
  for (const name of admin.auditFieldNames()) {
@@ -12964,6 +13639,44 @@ function makeAdminRouter(site, options) {
12964
13639
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12965
13640
  return row ?? null;
12966
13641
  }
13642
+ function renderImport(req, state, admin, error, created, rowErrors) {
13643
+ return renderImportPage(context(req, state.session, state.visible), {
13644
+ title: admin.verboseNamePlural(),
13645
+ actionUrl: `${prefix}/m/${admin.slug()}/import`,
13646
+ backUrl: `${prefix}/m/${admin.slug()}`,
13647
+ columns: admin.editableFieldNames(),
13648
+ error,
13649
+ created,
13650
+ rowErrors
13651
+ });
13652
+ }
13653
+ async function readSubmission(req) {
13654
+ if (!isMultipart(req)) {
13655
+ return { fields: req.body ?? {}, files: [] };
13656
+ }
13657
+ const parsed = await parseMultipart(req, { maxFileBytes: maxUploadBytes });
13658
+ return { fields: parsed.fields, files: parsed.files };
13659
+ }
13660
+ async function applyUploads(admin, data, errors, files, creating) {
13661
+ if (admin.uploadFields.length === 0) return;
13662
+ const storage2 = admin.uploadStorage;
13663
+ if (storage2 === null) return;
13664
+ const columns = adminColumns(admin.model);
13665
+ for (const field of admin.uploadFields) {
13666
+ const file = files.find((candidate) => candidate.field === field);
13667
+ if (file === void 0) {
13668
+ const column6 = columns[field];
13669
+ if (creating && column6 !== void 0 && !isColumnOptional(column6)) {
13670
+ errors[field] = "This field is required.";
13671
+ }
13672
+ continue;
13673
+ }
13674
+ const extension = file.filename.includes(".") ? `.${file.filename.split(".").pop()}` : "";
13675
+ const key = `${admin.slug()}/${field}/${crypto.randomUUID()}${extension}`;
13676
+ const saved = await storage2.save(key, file.data, { contentType: file.contentType });
13677
+ data[field] = saved.key;
13678
+ }
13679
+ }
12967
13680
  async function permittedBulkActions(admin, principal) {
12968
13681
  const permitted = [];
12969
13682
  for (const option of bulkActionsFor(admin)) {
@@ -13027,6 +13740,7 @@ function makeAdminRouter(site, options) {
13027
13740
  filters: query.filterViews,
13028
13741
  sort,
13029
13742
  newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
13743
+ importUrl: admin.canImport && await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/import` : null,
13030
13744
  bulkActions: await permittedBulkActions(admin, state.principal),
13031
13745
  bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
13032
13746
  exportCsvUrl: exportUrl("csv"),
@@ -13151,6 +13865,7 @@ function makeAdminRouter(site, options) {
13151
13865
  const columns = adminColumns(admin.model);
13152
13866
  const options2 = {};
13153
13867
  for (const field of Object.keys(foreignKeyFields(admin))) {
13868
+ if (admin.autocompleteFields.includes(field)) continue;
13154
13869
  const column6 = columns[field];
13155
13870
  if (column6 === void 0) continue;
13156
13871
  const related = await relatedOptions(column6, dbSession);
@@ -13158,8 +13873,60 @@ function makeAdminRouter(site, options) {
13158
13873
  }
13159
13874
  return options2;
13160
13875
  }
13876
+ function autocompleteUrlsFor(admin) {
13877
+ const urls = {};
13878
+ for (const field of admin.autocompleteFields) {
13879
+ urls[field] = `${prefix}/m/${admin.slug()}/autocomplete/${field}`;
13880
+ }
13881
+ return urls;
13882
+ }
13883
+ async function autocompleteLabelsFor(admin, row, dbSession) {
13884
+ const labels = {};
13885
+ if (row === null) return labels;
13886
+ const columns = adminColumns(admin.model);
13887
+ for (const field of admin.autocompleteFields) {
13888
+ const value = row[field];
13889
+ if (value === null || value === void 0 || value === "") continue;
13890
+ const column6 = columns[field];
13891
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13892
+ const referenced = table === null ? null : site.get(table);
13893
+ if (referenced === null) continue;
13894
+ const related = await referenced.repository(dbSession).first({ [referenced.identityField]: value });
13895
+ if (related !== null) labels[field] = foreignKeyLabel(referenced, related);
13896
+ }
13897
+ return labels;
13898
+ }
13161
13899
  return router;
13162
13900
  }
13901
+ function groupInlineSubmission(body) {
13902
+ const rows = {};
13903
+ const deletions = /* @__PURE__ */ new Set();
13904
+ for (const [key, raw] of Object.entries(body)) {
13905
+ if (!key.startsWith("row.")) continue;
13906
+ const parts = key.split(".");
13907
+ if (parts.length !== 3) continue;
13908
+ const [, rowKey, field] = parts;
13909
+ const value = typeof raw === "string" ? raw : "";
13910
+ if (field === "__delete") {
13911
+ if (!["", "false", "off", "0", "no"].includes(value.trim().toLowerCase())) {
13912
+ deletions.add(rowKey);
13913
+ }
13914
+ continue;
13915
+ }
13916
+ const row = rows[rowKey] ?? {};
13917
+ row[field] = value;
13918
+ rows[rowKey] = row;
13919
+ }
13920
+ return { rows, deletions };
13921
+ }
13922
+ function inlineFieldNames(childAdmin, inline) {
13923
+ return childAdmin.editableFieldNames().filter(
13924
+ (name) => name !== inline.fkField && !childAdmin.uploadFields.includes(name) && !childAdmin.autocompleteFields.includes(name)
13925
+ );
13926
+ }
13927
+ function inlineFields(childAdmin, names, key, values, errors) {
13928
+ return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
13929
+ }
13163
13930
  function flagAllows(admin, action) {
13164
13931
  if (action === AdminPermission.CREATE) return admin.canCreate;
13165
13932
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -13249,6 +14016,55 @@ function bulkActionsFor(admin) {
13249
14016
  }
13250
14017
  return actions;
13251
14018
  }
14019
+ function parseCsv(text) {
14020
+ const source = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
14021
+ const rows = [];
14022
+ let row = [];
14023
+ let field = "";
14024
+ let quoted = false;
14025
+ for (let index = 0; index < source.length; index += 1) {
14026
+ const char = source[index];
14027
+ if (quoted) {
14028
+ if (char === '"') {
14029
+ if (source[index + 1] === '"') {
14030
+ field += '"';
14031
+ index += 1;
14032
+ } else {
14033
+ quoted = false;
14034
+ }
14035
+ } else {
14036
+ field += char;
14037
+ }
14038
+ continue;
14039
+ }
14040
+ if (char === '"') {
14041
+ quoted = true;
14042
+ } else if (char === ",") {
14043
+ row.push(field);
14044
+ field = "";
14045
+ } else if (char === "\n" || char === "\r") {
14046
+ if (char === "\r" && source[index + 1] === "\n") index += 1;
14047
+ row.push(field);
14048
+ rows.push(row);
14049
+ row = [];
14050
+ field = "";
14051
+ } else {
14052
+ field += char;
14053
+ }
14054
+ }
14055
+ if (field !== "" || row.length > 0) {
14056
+ row.push(field);
14057
+ rows.push(row);
14058
+ }
14059
+ const header = rows.shift();
14060
+ if (header === void 0 || header.length === 0) {
14061
+ throw new Error("The file has no header row.");
14062
+ }
14063
+ const keys = header.map((name) => name.trim());
14064
+ return rows.filter((entry) => entry.some((value) => value.trim() !== "")).map(
14065
+ (entry) => Object.fromEntries(keys.map((key, position) => [key, entry[position] ?? ""]))
14066
+ );
14067
+ }
13252
14068
  function exportValue(value) {
13253
14069
  if (value instanceof Date) return value.toISOString();
13254
14070
  if (typeof value === "bigint") return value.toString();
@@ -15282,7 +16098,7 @@ async function withTestDatabase(models, fn) {
15282
16098
  }
15283
16099
 
15284
16100
  // src/version.ts
15285
- var VERSION = "0.26.0";
16101
+ var VERSION = "0.28.0";
15286
16102
 
15287
16103
  Object.defineProperty(exports, "OpenAPIRegistry", {
15288
16104
  enumerable: true,
@@ -15496,6 +16312,7 @@ exports.MessageCatalog = MessageCatalog;
15496
16312
  exports.MessagingHub = MessagingHub;
15497
16313
  exports.MetricsUtils = MetricsUtils;
15498
16314
  exports.MfaService = MfaService;
16315
+ exports.MultipartLimitError = MultipartLimitError;
15499
16316
  exports.NotFoundException = NotFoundException;
15500
16317
  exports.OAuthError = OAuthError;
15501
16318
  exports.OIDCProvider = OIDCProvider;
@@ -15541,6 +16358,7 @@ exports.activationSchema = activationSchema;
15541
16358
  exports.addLogSink = addLogSink;
15542
16359
  exports.adminAction = adminAction;
15543
16360
  exports.adminColumns = adminColumns;
16361
+ exports.adminInline = adminInline;
15544
16362
  exports.adminLens = adminLens;
15545
16363
  exports.adminThemeCss = adminThemeCss;
15546
16364
  exports.attachWebSocketHub = attachWebSocketHub;
@@ -15602,12 +16420,14 @@ exports.getConditions = getConditions;
15602
16420
  exports.getPaginationConditions = getPaginationConditions;
15603
16421
  exports.getRequestId = getRequestId;
15604
16422
  exports.getState = getState;
16423
+ exports.groupInlineSubmission = groupInlineSubmission;
15605
16424
  exports.hashOpaqueToken = hashOpaqueToken;
15606
16425
  exports.hexColorField = hexColorField;
15607
16426
  exports.humanizeField = humanizeField;
15608
16427
  exports.idempotencyMiddleware = idempotencyMiddleware;
15609
16428
  exports.inboundMessageSchema = inboundMessageSchema;
15610
16429
  exports.isColumnOptional = isColumnOptional;
16430
+ exports.isMultipart = isMultipart;
15611
16431
  exports.isSearchableColumn = isSearchableColumn;
15612
16432
  exports.isValidCep = isValidCep;
15613
16433
  exports.isValidCity = isValidCity;
@@ -15667,7 +16487,9 @@ exports.paginationFilterSchema = paginationFilterSchema;
15667
16487
  exports.paginationSchema = paginationSchema;
15668
16488
  exports.parseAcceptLanguage = parseAcceptLanguage;
15669
16489
  exports.parseCookies = parseCookies;
16490
+ exports.parseCsv = parseCsv;
15670
16491
  exports.parseFormBody = parseFormBody;
16492
+ exports.parseMultipart = parseMultipart;
15671
16493
  exports.partitionTotal = partitionTotal;
15672
16494
  exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
15673
16495
  exports.passwordResetRequestSchema = passwordResetRequestSchema;
@@ -15689,6 +16511,7 @@ exports.renderAuthResultPage = renderAuthResultPage;
15689
16511
  exports.renderDashboardPage = renderDashboardPage;
15690
16512
  exports.renderDetailPage = renderDetailPage;
15691
16513
  exports.renderFormPage = renderFormPage;
16514
+ exports.renderImportPage = renderImportPage;
15692
16515
  exports.renderLayout = renderLayout;
15693
16516
  exports.renderListPage = renderListPage;
15694
16517
  exports.renderLoginPage = renderLoginPage;