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.js CHANGED
@@ -1,5 +1,5 @@
1
- import { z, looseBoolean, toDict, PasswordUtils } from './chunk-GLZYNX63.js';
2
- export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-GLZYNX63.js';
1
+ import { z, looseBoolean, toDict, PasswordUtils } from './chunk-3NS5KVHT.js';
2
+ export { PasswordUtils, VERSION, baseAppSettingsSchema, baseAppSettingsShape, baseResponseSchema, centsField, corsSettingsShape, databaseSettingsShape, looseBoolean as envBoolean, hexColorField, latitudeField, loadSettings, longitudeField, looseBoolean, nonEmptyStrField, nonNegativeFloatField, nonNegativeIntField, percentField, portField, positiveFloatField, positiveIntField, priceField, ratingField, ratioField, serverSettingsShape, slugField, toDict, z } from './chunk-3NS5KVHT.js';
3
3
  import { AsyncLocalStorage } from 'async_hooks';
4
4
  import { Model, column, sql, BaseRepository, RecordNotFound, detectDialect, columnsOf, NodeSqliteDriver, AsyncEngine, or, and } from 'tempest-db-js';
5
5
  export { AsyncEngine, AsyncResult, AsyncSession, BaseRepository, Column, DeleteBuilder, InsertBuilder, Model, NoResultError, NodeSqliteDriver, PostgresDialect, RecordNotFound, SelectBuilder, SqliteDialect, SyncEngine, SyncSession, UpdateBuilder, and, belongsTo, column, columnsOf, createEngine, createSyncEngine, del, detectDialect, getDialect, hasMany, insert, join, loadRelations, not, or, parseDatabaseUrl, select, sql, update } from 'tempest-db-js';
@@ -9334,6 +9334,23 @@ function partitionTotal(partition) {
9334
9334
  return partition.segments.reduce((total, segment) => total + segment.value, 0);
9335
9335
  }
9336
9336
 
9337
+ // src/admin/inlines.ts
9338
+ function adminInline(options) {
9339
+ const slug = options.model.tablename;
9340
+ if (typeof slug !== "string" || slug === "") {
9341
+ throw new Error("adminInline requires a concrete model with a tablename");
9342
+ }
9343
+ return {
9344
+ model: options.model,
9345
+ slug,
9346
+ fkField: options.fkField,
9347
+ listDisplay: options.listDisplay === void 0 ? null : [...options.listDisplay],
9348
+ label: options.label ?? null,
9349
+ editable: options.editable ?? false,
9350
+ canDelete: options.canDelete ?? false
9351
+ };
9352
+ }
9353
+
9337
9354
  // src/admin/lenses.ts
9338
9355
  function slugify(name) {
9339
9356
  return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
@@ -9371,6 +9388,85 @@ function adminAction(options, handler) {
9371
9388
  };
9372
9389
  }
9373
9390
 
9391
+ // src/admin/multipart.ts
9392
+ var BUSBOY_HINT = "The admin's upload and CSV-import screens need the optional peer `busboy`. Install it with: npm install busboy";
9393
+ async function loadBusboy() {
9394
+ try {
9395
+ const module = await import('busboy');
9396
+ return module.default ?? module;
9397
+ } catch {
9398
+ throw new Error(BUSBOY_HINT);
9399
+ }
9400
+ }
9401
+ var MultipartLimitError = class extends Error {
9402
+ /**
9403
+ * @param message - The operator-facing explanation.
9404
+ */
9405
+ constructor(message) {
9406
+ super(message);
9407
+ this.name = "MultipartLimitError";
9408
+ }
9409
+ };
9410
+ async function parseMultipart(req, options = {}) {
9411
+ const maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
9412
+ const maxFiles = options.maxFiles ?? 10;
9413
+ const busboy = await loadBusboy();
9414
+ return await new Promise((resolve, reject) => {
9415
+ const fields = {};
9416
+ const files = [];
9417
+ let settled = false;
9418
+ const fail = (error) => {
9419
+ if (settled) return;
9420
+ settled = true;
9421
+ reject(error);
9422
+ };
9423
+ const parser = busboy({
9424
+ headers: req.headers,
9425
+ limits: { fileSize: maxFileBytes, files: maxFiles }
9426
+ });
9427
+ parser.on("field", ((name, value) => {
9428
+ fields[name] = value;
9429
+ }));
9430
+ parser.on("file", ((name, stream, info) => {
9431
+ const chunks = [];
9432
+ stream.on("data", ((chunk) => chunks.push(chunk)));
9433
+ stream.on("limit", () => {
9434
+ fail(
9435
+ new MultipartLimitError(
9436
+ `The file is larger than the ${Math.floor(maxFileBytes / 1024 / 1024)} MB limit.`
9437
+ )
9438
+ );
9439
+ });
9440
+ stream.on("end", () => {
9441
+ const data = Buffer.concat(chunks);
9442
+ const filename = (info.filename ?? "").split(/[/\\]/).pop() ?? "";
9443
+ if (filename === "" || data.length === 0) return;
9444
+ files.push({
9445
+ field: name,
9446
+ filename,
9447
+ contentType: info.mimeType ?? "application/octet-stream",
9448
+ data
9449
+ });
9450
+ });
9451
+ }));
9452
+ parser.on("filesLimit", () => {
9453
+ fail(new MultipartLimitError(`At most ${maxFiles} files can be uploaded at once.`));
9454
+ });
9455
+ parser.on("error", ((error) => {
9456
+ fail(error instanceof Error ? error : new Error(String(error)));
9457
+ }));
9458
+ parser.on("close", () => {
9459
+ if (settled) return;
9460
+ settled = true;
9461
+ resolve({ fields, files });
9462
+ });
9463
+ req.pipe(parser);
9464
+ });
9465
+ }
9466
+ function isMultipart(req) {
9467
+ return (req.header("content-type") ?? "").toLowerCase().startsWith("multipart/form-data");
9468
+ }
9469
+
9374
9470
  // src/admin/columns.ts
9375
9471
  function humanizeField(name) {
9376
9472
  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(" ");
@@ -9464,6 +9560,8 @@ var NEVER_EDITABLE = [
9464
9560
  "id",
9465
9561
  "createdAt",
9466
9562
  "updatedAt",
9563
+ "createdBy",
9564
+ "updatedBy",
9467
9565
  "hashedPassword"
9468
9566
  ];
9469
9567
  var NEVER_LISTED = ["hashedPassword"];
@@ -9500,6 +9598,16 @@ var AdminModel = class {
9500
9598
  auditModel;
9501
9599
  /** Saved list-view presets, in declaration order. */
9502
9600
  lenses;
9601
+ /** Columns rendered as file inputs. */
9602
+ uploadFields;
9603
+ /** Backend persisting uploaded files, or `null`. */
9604
+ uploadStorage;
9605
+ /** Whether the CSV import page is exposed. */
9606
+ canImport;
9607
+ /** Foreign-key columns rendered as a typed search box. */
9608
+ autocompleteFields;
9609
+ /** Related child models listed on the detail view. */
9610
+ inlines;
9503
9611
  actions = /* @__PURE__ */ new Map();
9504
9612
  slugOverride;
9505
9613
  listDisplayOverride;
@@ -9527,6 +9635,16 @@ var AdminModel = class {
9527
9635
  this.canDelete = options.canDelete ?? true;
9528
9636
  this.auditModel = options.auditModel ?? null;
9529
9637
  this.lenses = [...options.lenses ?? []];
9638
+ this.uploadFields = [...options.uploadFields ?? []];
9639
+ this.uploadStorage = options.uploadStorage ?? null;
9640
+ this.canImport = options.canImport ?? false;
9641
+ this.autocompleteFields = [...options.autocompleteFields ?? []];
9642
+ this.inlines = [...options.inlines ?? []];
9643
+ if (this.uploadFields.length > 0 && this.uploadStorage === null) {
9644
+ throw new Error(
9645
+ `AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
9646
+ );
9647
+ }
9530
9648
  for (const action of options.actions ?? []) {
9531
9649
  if (this.actions.has(action.name)) {
9532
9650
  throw new Error(
@@ -9541,7 +9659,9 @@ var AdminModel = class {
9541
9659
  ["listFilter", this.listFilter],
9542
9660
  ["searchFields", this.searchFields],
9543
9661
  ["readonlyFields", this.readonlyFields],
9544
- ["identityField", [this.identityField]]
9662
+ ["identityField", [this.identityField]],
9663
+ ["uploadFields", this.uploadFields],
9664
+ ["autocompleteFields", this.autocompleteFields]
9545
9665
  ]) {
9546
9666
  for (const name of names) {
9547
9667
  if (!known.has(name)) {
@@ -9723,11 +9843,15 @@ function buildFormFields(admin, options = {}) {
9723
9843
  const values = options.values ?? {};
9724
9844
  const errors = options.errors ?? {};
9725
9845
  const foreignKeys = options.foreignKeyOptions ?? {};
9846
+ const autocompleteUrls = options.autocompleteUrls ?? {};
9847
+ const autocompleteLabels = options.autocompleteLabels ?? {};
9848
+ const uploads = new Set(admin.uploadFields);
9726
9849
  return admin.editableFieldNames().flatMap((name) => {
9727
9850
  const column6 = columns[name];
9728
9851
  if (column6 === void 0) return [];
9729
9852
  const related = foreignKeys[name];
9730
- const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
9853
+ const autocompleteUrl = autocompleteUrls[name];
9854
+ 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 };
9731
9855
  const raw = name in values ? values[name] : literalDefault(column6);
9732
9856
  return [
9733
9857
  {
@@ -9739,7 +9863,9 @@ function buildFormFields(admin, options = {}) {
9739
9863
  checked: spec.widget === "checkbox" && toBoolean(raw),
9740
9864
  step: spec.step,
9741
9865
  options: spec.options,
9742
- error: errors[name] ?? null
9866
+ error: errors[name] ?? null,
9867
+ autocompleteUrl: autocompleteUrl ?? null,
9868
+ displayLabel: autocompleteLabels[name] ?? ""
9743
9869
  }
9744
9870
  ];
9745
9871
  });
@@ -9787,13 +9913,17 @@ function coerceValue(column6, widget, raw) {
9787
9913
  return raw;
9788
9914
  }
9789
9915
  }
9790
- function parseFormBody(admin, body) {
9916
+ function parseFormBody(admin, body, options = {}) {
9791
9917
  const columns = adminColumns(admin.model);
9792
9918
  const data = {};
9793
9919
  const errors = {};
9920
+ const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
9921
+ const only = options.only === void 0 ? null : new Set(options.only);
9794
9922
  for (const name of admin.editableFieldNames()) {
9923
+ if (only !== null && !only.has(name)) continue;
9795
9924
  const column6 = columns[name];
9796
9925
  if (column6 === void 0) continue;
9926
+ if (uploads.has(name)) continue;
9797
9927
  const { widget } = widgetForColumn(column6);
9798
9928
  if (widget === "checkbox") {
9799
9929
  data[name] = toBoolean(body[name]);
@@ -12079,6 +12209,7 @@ function renderListPage(context, view) {
12079
12209
  </form>
12080
12210
  <div class="tempest-admin-list__actions">
12081
12211
  ${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
12212
+ ${view.importUrl !== null ? `<a href="${escapeHtml(view.importUrl)}">Import CSV</a>` : ""}
12082
12213
  <a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
12083
12214
  <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12084
12215
  </div>
@@ -12123,10 +12254,97 @@ function renderDetailPage(context, view) {
12123
12254
  </div>
12124
12255
  </header>
12125
12256
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12257
+ ${view.inlines.map((inline) => renderInline(inline, context.session?.csrfToken ?? "", view.inlineError)).join("")}
12126
12258
  ${auditPanel}
12127
12259
  </section>`;
12128
12260
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12129
12261
  }
12262
+ function renderInlineCell(field) {
12263
+ const required = field.required ? " required" : "";
12264
+ const name = escapeHtml(field.name);
12265
+ const value = escapeHtml(field.value);
12266
+ let control;
12267
+ switch (field.widget) {
12268
+ case "checkbox":
12269
+ control = `<input type="checkbox" name="${name}" value="true"${field.checked ? " checked" : ""}>`;
12270
+ break;
12271
+ case "textarea":
12272
+ case "json":
12273
+ control = `<textarea name="${name}" rows="2"${required}>${value}</textarea>`;
12274
+ break;
12275
+ case "select": {
12276
+ const blank = field.required ? "" : '<option value="">\u2014 none \u2014</option>';
12277
+ const options = field.options.map(
12278
+ (option) => `<option value="${escapeHtml(option.value)}"${option.value === field.value ? " selected" : ""}>${escapeHtml(option.label)}</option>`
12279
+ ).join("");
12280
+ control = `<select name="${name}"${required}>${blank}${options}</select>`;
12281
+ break;
12282
+ }
12283
+ case "number":
12284
+ control = `<input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}>`;
12285
+ break;
12286
+ case "datetime":
12287
+ control = `<input type="datetime-local" name="${name}" value="${value}"${required}>`;
12288
+ break;
12289
+ case "date":
12290
+ control = `<input type="date" name="${name}" value="${value}"${required}>`;
12291
+ break;
12292
+ case "time":
12293
+ control = `<input type="time" name="${name}" value="${value}"${required}>`;
12294
+ break;
12295
+ default:
12296
+ control = `<input type="text" name="${name}" value="${value}"${required}>`;
12297
+ }
12298
+ const error = field.error !== null ? `<small class="tempest-admin-form__field-error">${escapeHtml(field.error)}</small>` : "";
12299
+ return `${control}${error}`;
12300
+ }
12301
+ function renderInline(inline, csrfToken, error) {
12302
+ const heading = `<header class="tempest-admin-inline__header">
12303
+ <h2>${escapeHtml(inline.label)}${inline.total > 0 ? ` <span class="tempest-admin-inline__count">(${escapeHtml(inline.total)})</span>` : ""}</h2>
12304
+ ${inline.addUrl !== null ? `<a class="tempest-admin-btn" href="${escapeHtml(inline.addUrl)}">Add</a>` : ""}
12305
+ </header>`;
12306
+ const more = inline.truncated ? `<p class="tempest-admin-inline__more"><em>Showing the first ${escapeHtml(inline.rows.length)} of ${escapeHtml(inline.total)}.</em></p>` : "";
12307
+ if (!inline.editable) {
12308
+ if (inline.rows.length === 0) {
12309
+ return `<section class="tempest-admin-inline">${heading}<p><em>No related records.</em></p></section>`;
12310
+ }
12311
+ const head2 = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}<th></th></tr>`;
12312
+ const body = inline.rows.map(
12313
+ (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td>${row.url === null ? "" : `<a href="${escapeHtml(row.url)}">View</a>`}</td></tr>`
12314
+ ).join("");
12315
+ return `<section class="tempest-admin-inline">
12316
+ ${heading}
12317
+ <div class="tempest-admin-inline__scroll">
12318
+ <table class="tempest-admin-inline__table">
12319
+ <thead>${head2}</thead>
12320
+ <tbody>${body}</tbody>
12321
+ </table>
12322
+ </div>
12323
+ ${more}
12324
+ </section>`;
12325
+ }
12326
+ const head = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12327
+ 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>`;
12328
+ const rows = inline.rows.map((row) => renderRow(row, false)).join("");
12329
+ const blank = inline.newRow === null ? "" : renderRow(inline.newRow, true);
12330
+ return `<section class="tempest-admin-inline">
12331
+ ${heading}
12332
+ ${error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(error)}</p>` : ""}
12333
+ <form method="post" action="${escapeHtml(inline.formAction)}" class="tempest-admin-inline__form">
12334
+ <input type="hidden" name="csrf_token" value="${escapeHtml(csrfToken)}">
12335
+ <div class="tempest-admin-inline__scroll">
12336
+ <table class="tempest-admin-inline__table">
12337
+ <thead>${head}</thead>
12338
+ <tbody>${rows}${blank}</tbody>
12339
+ </table>
12340
+ </div>
12341
+ <div class="tempest-admin-form__actions">
12342
+ <button type="submit">Save ${escapeHtml(inline.label)}</button>
12343
+ </div>
12344
+ </form>
12345
+ ${more}
12346
+ </section>`;
12347
+ }
12130
12348
  function renderAuditPanel(audit) {
12131
12349
  const rows = audit.fields.map(
12132
12350
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
@@ -12178,6 +12396,18 @@ function renderFormField(field) {
12178
12396
  control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
12179
12397
  break;
12180
12398
  }
12399
+ case "file":
12400
+ 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>`}`;
12401
+ break;
12402
+ case "autocomplete":
12403
+ control = `<label><span>${label}${field.required ? " *" : ""}</span>
12404
+ <div class="tempest-admin-ac" data-ac data-ac-url="${escapeHtml(field.autocompleteUrl)}">
12405
+ <input type="text" class="tempest-admin-ac__search" value="${escapeHtml(field.displayLabel)}" placeholder="Search\u2026" autocomplete="off" data-ac-search>
12406
+ <input type="hidden" name="${name}" value="${value}"${required} data-ac-value>
12407
+ <ul class="tempest-admin-ac__results" data-ac-results hidden></ul>
12408
+ </div>
12409
+ </label>`;
12410
+ break;
12181
12411
  case "number":
12182
12412
  control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
12183
12413
  break;
@@ -12205,7 +12435,7 @@ function renderFormPage(context, view) {
12205
12435
  <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12206
12436
  </header>
12207
12437
  ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12208
- <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form">
12438
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form"${view.fields.some((field) => field.widget === "file") ? ' enctype="multipart/form-data"' : ""}>
12209
12439
  <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12210
12440
  ${view.fields.map(renderFormField).join("")}
12211
12441
  <div class="tempest-admin-form__actions">
@@ -12213,10 +12443,98 @@ function renderFormPage(context, view) {
12213
12443
  <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12214
12444
  </div>
12215
12445
  </form>
12446
+ ${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
12216
12447
  </section>`;
12217
12448
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12218
12449
  }
12450
+ function renderImportPage(context, view) {
12451
+ if (context.session === null) throw new Error("The import page requires a session");
12452
+ const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
12453
+ 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(
12454
+ (failure) => `<tr><td>${escapeHtml(failure.row)}</td><td>${escapeHtml(failure.message)}</td></tr>`
12455
+ ).join("")}</tbody></table>` : "";
12456
+ const body = `<section class="tempest-admin-import">
12457
+ <header class="tempest-admin-detail__header">
12458
+ <h1>Import ${escapeHtml(view.title)}</h1>
12459
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
12460
+ </header>
12461
+ ${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
12462
+ ${summary}
12463
+ ${failures}
12464
+ <form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form" enctype="multipart/form-data">
12465
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
12466
+ <div class="tempest-admin-form__field">
12467
+ <label>
12468
+ <span>CSV file *</span>
12469
+ <input type="file" name="file" accept=".csv,text/csv" required>
12470
+ </label>
12471
+ <small class="tempest-admin-form__hint">
12472
+ UTF-8, comma-separated, with a header row. Recognised columns:
12473
+ <code>${escapeHtml(view.columns.join(", "))}</code>. Unknown columns are ignored.
12474
+ </small>
12475
+ </div>
12476
+ <div class="tempest-admin-form__actions">
12477
+ <button type="submit">Import</button>
12478
+ <a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
12479
+ </div>
12480
+ </form>
12481
+ </section>`;
12482
+ return renderLayout(context, `Import ${view.title} \xB7 ${context.site.title}`, body);
12483
+ }
12484
+ var AUTOCOMPLETE_SCRIPT = `<script>
12485
+ (function () {
12486
+ document.querySelectorAll("[data-ac]").forEach(function (box) {
12487
+ var search = box.querySelector("[data-ac-search]");
12488
+ var value = box.querySelector("[data-ac-value]");
12489
+ var results = box.querySelector("[data-ac-results]");
12490
+ var url = box.getAttribute("data-ac-url");
12491
+ var timer = null;
12492
+
12493
+ function close() {
12494
+ results.hidden = true;
12495
+ results.innerHTML = "";
12496
+ }
12497
+
12498
+ function pick(option) {
12499
+ value.value = option.value;
12500
+ search.value = option.label;
12501
+ close();
12502
+ }
12503
+
12504
+ function run() {
12505
+ var term = search.value.trim();
12506
+ fetch(url + "?q=" + encodeURIComponent(term), { credentials: "same-origin" })
12507
+ .then(function (response) { return response.ok ? response.json() : { options: [] }; })
12508
+ .then(function (payload) {
12509
+ results.innerHTML = "";
12510
+ (payload.options || []).forEach(function (option) {
12511
+ var item = document.createElement("li");
12512
+ item.textContent = option.label;
12513
+ item.setAttribute("role", "option");
12514
+ item.addEventListener("mousedown", function (event) {
12515
+ event.preventDefault();
12516
+ pick(option);
12517
+ });
12518
+ results.appendChild(item);
12519
+ });
12520
+ results.hidden = results.children.length === 0;
12521
+ })
12522
+ .catch(close);
12523
+ }
12524
+
12525
+ search.addEventListener("input", function () {
12526
+ value.value = "";
12527
+ window.clearTimeout(timer);
12528
+ timer = window.setTimeout(run, 250);
12529
+ });
12530
+ search.addEventListener("focus", run);
12531
+ search.addEventListener("blur", function () { window.setTimeout(close, 150); });
12532
+ });
12533
+ })();
12534
+ </script>`;
12219
12535
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12536
+ var INLINE_ROW_LIMIT = 50;
12537
+ var AUTOCOMPLETE_LIMIT = 20;
12220
12538
  var AUDIT_HISTORY_LIMIT = 50;
12221
12539
  var FK_OPTION_CAP = 1e3;
12222
12540
  var FLASH_MAX_LENGTH = 300;
@@ -12246,6 +12564,7 @@ function makeAdminRouter(site, options) {
12246
12564
  const theme = resolveAdminTheme(site.theme);
12247
12565
  const showMetrics = options.showMetrics ?? true;
12248
12566
  const exportMaxRows = options.exportMaxRows ?? 5e3;
12567
+ const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
12249
12568
  const sessions = new AdminSessionStore({
12250
12569
  secret: options.secretKey,
12251
12570
  ...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
@@ -12503,7 +12822,8 @@ function makeAdminRouter(site, options) {
12503
12822
  mode: "create",
12504
12823
  title: admin.verboseName(),
12505
12824
  fields: buildFormFields(admin, {
12506
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
12825
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
12826
+ autocompleteUrls: autocompleteUrlsFor(admin)
12507
12827
  }),
12508
12828
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12509
12829
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12523,10 +12843,28 @@ function makeAdminRouter(site, options) {
12523
12843
  html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12524
12844
  return;
12525
12845
  }
12526
- if (!checkCsrf(req, res, state)) return;
12527
- const body = req.body;
12846
+ let uploadError = null;
12847
+ let submission;
12848
+ try {
12849
+ submission = await readSubmission(req);
12850
+ } catch (error) {
12851
+ if (!(error instanceof MultipartLimitError)) throw error;
12852
+ submission = { fields: {}, files: [] };
12853
+ uploadError = error.message;
12854
+ }
12855
+ const body = submission.fields;
12856
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
12857
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12858
+ return;
12859
+ }
12528
12860
  const parsed = parseFormBody(admin, body);
12861
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
12529
12862
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
12863
+ const autocompleteLabels = await autocompleteLabelsFor(
12864
+ admin,
12865
+ body,
12866
+ state.dbSession
12867
+ );
12530
12868
  const rerender = (error, status) => {
12531
12869
  html(
12532
12870
  res,
@@ -12536,7 +12874,9 @@ function makeAdminRouter(site, options) {
12536
12874
  fields: buildFormFields(admin, {
12537
12875
  values: body,
12538
12876
  errors: parsed.errors,
12539
- foreignKeyOptions
12877
+ foreignKeyOptions,
12878
+ autocompleteUrls: autocompleteUrlsFor(admin),
12879
+ autocompleteLabels
12540
12880
  }),
12541
12881
  actionUrl: `${prefix}/m/${admin.slug()}/new`,
12542
12882
  backUrl: `${prefix}/m/${admin.slug()}`,
@@ -12545,6 +12885,10 @@ function makeAdminRouter(site, options) {
12545
12885
  status
12546
12886
  );
12547
12887
  };
12888
+ if (uploadError !== null) {
12889
+ rerender(uploadError, 400);
12890
+ return;
12891
+ }
12548
12892
  if (Object.keys(parsed.errors).length > 0) {
12549
12893
  rerender("Please fix the highlighted fields.", 400);
12550
12894
  return;
@@ -12664,6 +13008,129 @@ function makeAdminRouter(site, options) {
12664
13008
  );
12665
13009
  })
12666
13010
  );
13011
+ router.get(
13012
+ `${prefix}/m/:slug/import`,
13013
+ guarded(async (req, res) => {
13014
+ const state = await authenticate(req, res);
13015
+ if (state === null) return;
13016
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
13017
+ if (admin === null) return;
13018
+ if (!admin.canImport) {
13019
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13020
+ return;
13021
+ }
13022
+ html(res, renderImport(req, state, admin, null, null, []));
13023
+ })
13024
+ );
13025
+ router.post(
13026
+ `${prefix}/m/:slug/import`,
13027
+ guarded(async (req, res) => {
13028
+ const state = await authenticate(req, res);
13029
+ if (state === null) return;
13030
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
13031
+ if (admin === null) return;
13032
+ if (!admin.canImport) {
13033
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13034
+ return;
13035
+ }
13036
+ let submission;
13037
+ try {
13038
+ submission = await readSubmission(req);
13039
+ } catch (error) {
13040
+ if (!(error instanceof MultipartLimitError)) throw error;
13041
+ html(res, renderImport(req, state, admin, error.message, null, []), 400);
13042
+ return;
13043
+ }
13044
+ if (!csrfTokenMatches(state.session, submission.fields.csrf_token)) {
13045
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
13046
+ return;
13047
+ }
13048
+ const file = submission.files[0];
13049
+ if (file === void 0) {
13050
+ html(
13051
+ res,
13052
+ renderImport(req, state, admin, "Choose a CSV file to import.", null, []),
13053
+ 400
13054
+ );
13055
+ return;
13056
+ }
13057
+ let rows;
13058
+ try {
13059
+ rows = parseCsv(file.data.toString("utf8"));
13060
+ } catch (error) {
13061
+ const message = error instanceof Error ? error.message : "Could not read the file as UTF-8 CSV.";
13062
+ html(res, renderImport(req, state, admin, message, null, []), 400);
13063
+ return;
13064
+ }
13065
+ const repository = admin.repository(state.dbSession);
13066
+ const actorId = backend.principalId(state.principal);
13067
+ const rowErrors = [];
13068
+ let created = 0;
13069
+ for (const [index, row] of rows.entries()) {
13070
+ const parsed = parseFormBody(admin, row, { uploadsAsText: true });
13071
+ const failures = Object.entries(parsed.errors);
13072
+ if (failures.length > 0) {
13073
+ rowErrors.push({
13074
+ row: index + 2,
13075
+ message: failures.map(([field, error]) => `${field}: ${error}`).join("; ")
13076
+ });
13077
+ continue;
13078
+ }
13079
+ stampActor(admin, parsed.data, actorId, true);
13080
+ try {
13081
+ await repository.create(parsed.data);
13082
+ created += 1;
13083
+ } catch (error) {
13084
+ rowErrors.push({
13085
+ row: index + 2,
13086
+ message: error instanceof Error ? error.message : String(error)
13087
+ });
13088
+ }
13089
+ }
13090
+ html(res, renderImport(req, state, admin, null, created, rowErrors));
13091
+ })
13092
+ );
13093
+ router.get(
13094
+ `${prefix}/m/:slug/autocomplete/:field`,
13095
+ guarded(async (req, res) => {
13096
+ if (sessions.load(req) === null) {
13097
+ res.status(401).json({ options: [] });
13098
+ return;
13099
+ }
13100
+ const state = await authenticate(req, res);
13101
+ if (state === null) return;
13102
+ const admin = site.get(String(req.params.slug));
13103
+ const field = String(req.params.field);
13104
+ if (admin === null || !admin.autocompleteFields.includes(field) || !await allows(state.principal, admin, AdminPermission.VIEW)) {
13105
+ res.status(404).json({ options: [] });
13106
+ return;
13107
+ }
13108
+ const column6 = adminColumns(admin.model)[field];
13109
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13110
+ const referenced = table === null ? null : site.get(table);
13111
+ if (referenced === null) {
13112
+ res.json({ options: [] });
13113
+ return;
13114
+ }
13115
+ const term = queryString(req.query.q);
13116
+ const searchable = referenced.searchFields.filter((name) => {
13117
+ const target = adminColumns(referenced.model)[name];
13118
+ return target !== void 0 && isSearchableColumn(target);
13119
+ });
13120
+ const filters = term === "" || searchable.length === 0 ? void 0 : or(...searchable.map((name) => ({ [name]: { ilike: `%${term}%` } })));
13121
+ const page2 = await referenced.repository(state.dbSession).paginate({
13122
+ page: 1,
13123
+ pageSize: AUTOCOMPLETE_LIMIT,
13124
+ ...filters === void 0 ? {} : { filters }
13125
+ });
13126
+ res.json({
13127
+ options: page2.items.map((row) => ({
13128
+ value: String(row[referenced.identityField]),
13129
+ label: foreignKeyLabel(referenced, row)
13130
+ }))
13131
+ });
13132
+ })
13133
+ );
12667
13134
  router.get(
12668
13135
  `${prefix}/m/:slug/:identity`,
12669
13136
  guarded(async (req, res) => {
@@ -12683,6 +13150,8 @@ function makeAdminRouter(site, options) {
12683
13150
  title: admin.verboseName(),
12684
13151
  identity,
12685
13152
  audit: await buildAuditView(admin, row, state.dbSession),
13153
+ inlines: await buildInlines(admin, row, state, identity),
13154
+ inlineError: null,
12686
13155
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
12687
13156
  backUrl: `${prefix}/m/${admin.slug()}`,
12688
13157
  editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
@@ -12711,7 +13180,9 @@ function makeAdminRouter(site, options) {
12711
13180
  title: admin.verboseName(),
12712
13181
  fields: buildFormFields(admin, {
12713
13182
  values: row,
12714
- foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
13183
+ foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
13184
+ autocompleteUrls: autocompleteUrlsFor(admin),
13185
+ autocompleteLabels: await autocompleteLabelsFor(admin, row, state.dbSession)
12715
13186
  }),
12716
13187
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12717
13188
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12732,10 +13203,28 @@ function makeAdminRouter(site, options) {
12732
13203
  html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12733
13204
  return;
12734
13205
  }
12735
- if (!checkCsrf(req, res, state)) return;
12736
- const body = req.body;
13206
+ let uploadError = null;
13207
+ let submission;
13208
+ try {
13209
+ submission = await readSubmission(req);
13210
+ } catch (error) {
13211
+ if (!(error instanceof MultipartLimitError)) throw error;
13212
+ submission = { fields: {}, files: [] };
13213
+ uploadError = error.message;
13214
+ }
13215
+ const body = submission.fields;
13216
+ if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
13217
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
13218
+ return;
13219
+ }
12737
13220
  const parsed = parseFormBody(admin, body);
13221
+ await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
12738
13222
  const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
13223
+ const autocompleteLabels = await autocompleteLabelsFor(
13224
+ admin,
13225
+ body,
13226
+ state.dbSession
13227
+ );
12739
13228
  const rerender = (error, status) => {
12740
13229
  html(
12741
13230
  res,
@@ -12745,7 +13234,9 @@ function makeAdminRouter(site, options) {
12745
13234
  fields: buildFormFields(admin, {
12746
13235
  values: body,
12747
13236
  errors: parsed.errors,
12748
- foreignKeyOptions
13237
+ foreignKeyOptions,
13238
+ autocompleteUrls: autocompleteUrlsFor(admin),
13239
+ autocompleteLabels
12749
13240
  }),
12750
13241
  actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
12751
13242
  backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
@@ -12754,6 +13245,10 @@ function makeAdminRouter(site, options) {
12754
13245
  status
12755
13246
  );
12756
13247
  };
13248
+ if (uploadError !== null) {
13249
+ rerender(uploadError, 400);
13250
+ return;
13251
+ }
12757
13252
  if (Object.keys(parsed.errors).length > 0) {
12758
13253
  rerender("Please fix the highlighted fields.", 400);
12759
13254
  return;
@@ -12777,6 +13272,103 @@ function makeAdminRouter(site, options) {
12777
13272
  res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
12778
13273
  })
12779
13274
  );
13275
+ router.post(
13276
+ `${prefix}/m/:slug/:identity/inlines/:child`,
13277
+ guarded(async (req, res) => {
13278
+ const state = await authenticate(req, res);
13279
+ if (state === null) return;
13280
+ const admin = await resolveAdmin(req, res, state);
13281
+ if (admin === null) return;
13282
+ if (!checkCsrf(req, res, state)) return;
13283
+ const childSlug = String(req.params.child);
13284
+ const inline = admin.inlines.find(
13285
+ (entry) => entry.slug === childSlug && entry.editable
13286
+ );
13287
+ const childAdmin = inline === void 0 ? null : site.get(childSlug);
13288
+ if (inline === void 0 || childAdmin === null || !await allows(state.principal, childAdmin, AdminPermission.EDIT)) {
13289
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13290
+ return;
13291
+ }
13292
+ const identity = String(req.params.identity);
13293
+ const parent = await findRow(admin, state.dbSession, identity);
13294
+ if (parent === null) {
13295
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13296
+ return;
13297
+ }
13298
+ const parentId = parent[admin.identityField];
13299
+ const { rows: grouped, deletions } = groupInlineSubmission(
13300
+ req.body
13301
+ );
13302
+ const names = inlineFieldNames(childAdmin, inline);
13303
+ const childRepo = childAdmin.repository(state.dbSession);
13304
+ const actorId = backend.principalId(state.principal);
13305
+ const canDelete = inline.canDelete && await allows(state.principal, childAdmin, AdminPermission.DELETE);
13306
+ const failed = [];
13307
+ let formError = null;
13308
+ const owned = async (key) => await childRepo.first({
13309
+ [childAdmin.identityField]: key,
13310
+ [inline.fkField]: parentId
13311
+ });
13312
+ for (const [key, values] of Object.entries(grouped)) {
13313
+ const isNew = key.startsWith("new");
13314
+ if (!isNew && canDelete && deletions.has(key)) {
13315
+ if (await owned(key) !== null) {
13316
+ await childRepo.delete({ [childAdmin.identityField]: key });
13317
+ }
13318
+ continue;
13319
+ }
13320
+ if (isNew && Object.values(values).every((value) => value.trim() === "")) {
13321
+ continue;
13322
+ }
13323
+ const parsed = parseFormBody(childAdmin, values, { only: names });
13324
+ if (Object.keys(parsed.errors).length > 0) {
13325
+ failed.push({ key, values, errors: parsed.errors });
13326
+ continue;
13327
+ }
13328
+ try {
13329
+ if (isNew) {
13330
+ stampActor(childAdmin, parsed.data, actorId, true);
13331
+ await childRepo.create({
13332
+ ...parsed.data,
13333
+ [inline.fkField]: parentId
13334
+ });
13335
+ } else {
13336
+ if (await owned(key) === null) continue;
13337
+ stampActor(childAdmin, parsed.data, actorId, false);
13338
+ await childRepo.update(
13339
+ { [childAdmin.identityField]: key },
13340
+ parsed.data
13341
+ );
13342
+ }
13343
+ } catch (error) {
13344
+ formError = describeWriteFailure(childAdmin, error);
13345
+ failed.push({ key, values, errors: {} });
13346
+ }
13347
+ }
13348
+ if (failed.length > 0) {
13349
+ const fresh = await findRow(admin, state.dbSession, identity) ?? parent;
13350
+ html(
13351
+ res,
13352
+ renderDetailPage(context(req, state.session, state.visible), {
13353
+ title: admin.verboseName(),
13354
+ identity,
13355
+ audit: await buildAuditView(admin, fresh, state.dbSession),
13356
+ inlines: await buildInlines(admin, fresh, state, identity, {
13357
+ [childSlug]: failed
13358
+ }),
13359
+ inlineError: formError ?? "Some inline rows could not be saved.",
13360
+ fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(fresh[name]) })),
13361
+ backUrl: `${prefix}/m/${admin.slug()}`,
13362
+ editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
13363
+ deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
13364
+ }),
13365
+ 400
13366
+ );
13367
+ return;
13368
+ }
13369
+ res.redirect(`${prefix}/m/${admin.slug()}/${identity}?ok=updated`);
13370
+ })
13371
+ );
12780
13372
  router.post(
12781
13373
  `${prefix}/m/:slug/:identity/delete`,
12782
13374
  guarded(async (req, res) => {
@@ -12798,6 +13390,89 @@ function makeAdminRouter(site, options) {
12798
13390
  const principal = await backend.loadPrincipal(dbSession, String(actor));
12799
13391
  return principal === null ? String(actor) : backend.displayName(principal);
12800
13392
  }
13393
+ async function buildInlines(admin, parent, state, identity, overrides = {}) {
13394
+ const parentId = parent[admin.identityField];
13395
+ const blocks = [];
13396
+ for (const inline of admin.inlines) {
13397
+ const childAdmin = site.get(inline.slug);
13398
+ const repository = childAdmin === null ? new BaseRepository(inline.model, state.dbSession) : childAdmin.repository(state.dbSession);
13399
+ const children = await repository.list({
13400
+ [inline.fkField]: parentId
13401
+ });
13402
+ const columns = inline.listDisplay ?? childAdmin?.listDisplayNames() ?? Object.keys(adminColumns(inline.model));
13403
+ const label = inline.label ?? childAdmin?.verboseNamePlural() ?? humanizeField(inline.slug);
13404
+ const visible = children.slice(0, INLINE_ROW_LIMIT);
13405
+ const editable = inline.editable && childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.EDIT);
13406
+ const addUrl = childAdmin !== null && await allows(state.principal, childAdmin, AdminPermission.CREATE) ? `${prefix}/m/${inline.slug}/new` : null;
13407
+ if (!editable || childAdmin === null) {
13408
+ blocks.push({
13409
+ label,
13410
+ total: children.length,
13411
+ columns,
13412
+ editable: false,
13413
+ canDelete: false,
13414
+ addUrl,
13415
+ formAction: "",
13416
+ rows: visible.map((child) => ({
13417
+ key: String(child[childAdmin?.identityField ?? "id"]),
13418
+ cells: columns.map((column6) => formatCellValue(child[column6])),
13419
+ fields: [],
13420
+ url: childAdmin === null ? null : `${prefix}/m/${inline.slug}/${String(child[childAdmin.identityField])}`
13421
+ })),
13422
+ newRow: null,
13423
+ truncated: children.length > visible.length
13424
+ });
13425
+ continue;
13426
+ }
13427
+ const names = inlineFieldNames(childAdmin, inline);
13428
+ const submitted = overrides[inline.slug];
13429
+ const rows = [];
13430
+ if (submitted !== void 0) {
13431
+ for (const entry of submitted) {
13432
+ rows.push({
13433
+ key: entry.key,
13434
+ cells: [],
13435
+ fields: inlineFields(
13436
+ childAdmin,
13437
+ names,
13438
+ entry.key,
13439
+ entry.values,
13440
+ entry.errors
13441
+ ),
13442
+ url: null
13443
+ });
13444
+ }
13445
+ } else {
13446
+ for (const child of visible) {
13447
+ const key = String(child[childAdmin.identityField]);
13448
+ rows.push({
13449
+ key,
13450
+ cells: [],
13451
+ fields: inlineFields(childAdmin, names, key, child, {}),
13452
+ url: `${prefix}/m/${inline.slug}/${key}`
13453
+ });
13454
+ }
13455
+ }
13456
+ blocks.push({
13457
+ label,
13458
+ total: children.length,
13459
+ columns: names.map(humanizeField),
13460
+ editable: true,
13461
+ canDelete: inline.canDelete && childAdmin.canDelete,
13462
+ addUrl,
13463
+ formAction: `${prefix}/m/${admin.slug()}/${identity}/inlines/${inline.slug}`,
13464
+ rows,
13465
+ newRow: {
13466
+ key: "new1",
13467
+ cells: [],
13468
+ fields: inlineFields(childAdmin, names, "new1", {}, {}),
13469
+ url: null
13470
+ },
13471
+ truncated: children.length > visible.length
13472
+ });
13473
+ }
13474
+ return blocks;
13475
+ }
12801
13476
  async function buildAuditView(admin, row, dbSession) {
12802
13477
  const fields = [];
12803
13478
  for (const name of admin.auditFieldNames()) {
@@ -12837,6 +13512,44 @@ function makeAdminRouter(site, options) {
12837
13512
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12838
13513
  return row ?? null;
12839
13514
  }
13515
+ function renderImport(req, state, admin, error, created, rowErrors) {
13516
+ return renderImportPage(context(req, state.session, state.visible), {
13517
+ title: admin.verboseNamePlural(),
13518
+ actionUrl: `${prefix}/m/${admin.slug()}/import`,
13519
+ backUrl: `${prefix}/m/${admin.slug()}`,
13520
+ columns: admin.editableFieldNames(),
13521
+ error,
13522
+ created,
13523
+ rowErrors
13524
+ });
13525
+ }
13526
+ async function readSubmission(req) {
13527
+ if (!isMultipart(req)) {
13528
+ return { fields: req.body ?? {}, files: [] };
13529
+ }
13530
+ const parsed = await parseMultipart(req, { maxFileBytes: maxUploadBytes });
13531
+ return { fields: parsed.fields, files: parsed.files };
13532
+ }
13533
+ async function applyUploads(admin, data, errors, files, creating) {
13534
+ if (admin.uploadFields.length === 0) return;
13535
+ const storage2 = admin.uploadStorage;
13536
+ if (storage2 === null) return;
13537
+ const columns = adminColumns(admin.model);
13538
+ for (const field of admin.uploadFields) {
13539
+ const file = files.find((candidate) => candidate.field === field);
13540
+ if (file === void 0) {
13541
+ const column6 = columns[field];
13542
+ if (creating && column6 !== void 0 && !isColumnOptional(column6)) {
13543
+ errors[field] = "This field is required.";
13544
+ }
13545
+ continue;
13546
+ }
13547
+ const extension = file.filename.includes(".") ? `.${file.filename.split(".").pop()}` : "";
13548
+ const key = `${admin.slug()}/${field}/${randomUUID()}${extension}`;
13549
+ const saved = await storage2.save(key, file.data, { contentType: file.contentType });
13550
+ data[field] = saved.key;
13551
+ }
13552
+ }
12840
13553
  async function permittedBulkActions(admin, principal) {
12841
13554
  const permitted = [];
12842
13555
  for (const option of bulkActionsFor(admin)) {
@@ -12900,6 +13613,7 @@ function makeAdminRouter(site, options) {
12900
13613
  filters: query.filterViews,
12901
13614
  sort,
12902
13615
  newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
13616
+ importUrl: admin.canImport && await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/import` : null,
12903
13617
  bulkActions: await permittedBulkActions(admin, state.principal),
12904
13618
  bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12905
13619
  exportCsvUrl: exportUrl("csv"),
@@ -13024,6 +13738,7 @@ function makeAdminRouter(site, options) {
13024
13738
  const columns = adminColumns(admin.model);
13025
13739
  const options2 = {};
13026
13740
  for (const field of Object.keys(foreignKeyFields(admin))) {
13741
+ if (admin.autocompleteFields.includes(field)) continue;
13027
13742
  const column6 = columns[field];
13028
13743
  if (column6 === void 0) continue;
13029
13744
  const related = await relatedOptions(column6, dbSession);
@@ -13031,8 +13746,60 @@ function makeAdminRouter(site, options) {
13031
13746
  }
13032
13747
  return options2;
13033
13748
  }
13749
+ function autocompleteUrlsFor(admin) {
13750
+ const urls = {};
13751
+ for (const field of admin.autocompleteFields) {
13752
+ urls[field] = `${prefix}/m/${admin.slug()}/autocomplete/${field}`;
13753
+ }
13754
+ return urls;
13755
+ }
13756
+ async function autocompleteLabelsFor(admin, row, dbSession) {
13757
+ const labels = {};
13758
+ if (row === null) return labels;
13759
+ const columns = adminColumns(admin.model);
13760
+ for (const field of admin.autocompleteFields) {
13761
+ const value = row[field];
13762
+ if (value === null || value === void 0 || value === "") continue;
13763
+ const column6 = columns[field];
13764
+ const table = column6 === void 0 ? null : foreignKeyTable(column6);
13765
+ const referenced = table === null ? null : site.get(table);
13766
+ if (referenced === null) continue;
13767
+ const related = await referenced.repository(dbSession).first({ [referenced.identityField]: value });
13768
+ if (related !== null) labels[field] = foreignKeyLabel(referenced, related);
13769
+ }
13770
+ return labels;
13771
+ }
13034
13772
  return router;
13035
13773
  }
13774
+ function groupInlineSubmission(body) {
13775
+ const rows = {};
13776
+ const deletions = /* @__PURE__ */ new Set();
13777
+ for (const [key, raw] of Object.entries(body)) {
13778
+ if (!key.startsWith("row.")) continue;
13779
+ const parts = key.split(".");
13780
+ if (parts.length !== 3) continue;
13781
+ const [, rowKey, field] = parts;
13782
+ const value = typeof raw === "string" ? raw : "";
13783
+ if (field === "__delete") {
13784
+ if (!["", "false", "off", "0", "no"].includes(value.trim().toLowerCase())) {
13785
+ deletions.add(rowKey);
13786
+ }
13787
+ continue;
13788
+ }
13789
+ const row = rows[rowKey] ?? {};
13790
+ row[field] = value;
13791
+ rows[rowKey] = row;
13792
+ }
13793
+ return { rows, deletions };
13794
+ }
13795
+ function inlineFieldNames(childAdmin, inline) {
13796
+ return childAdmin.editableFieldNames().filter(
13797
+ (name) => name !== inline.fkField && !childAdmin.uploadFields.includes(name) && !childAdmin.autocompleteFields.includes(name)
13798
+ );
13799
+ }
13800
+ function inlineFields(childAdmin, names, key, values, errors) {
13801
+ return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
13802
+ }
13036
13803
  function flagAllows(admin, action) {
13037
13804
  if (action === AdminPermission.CREATE) return admin.canCreate;
13038
13805
  if (action === AdminPermission.EDIT) return admin.canEdit;
@@ -13122,6 +13889,55 @@ function bulkActionsFor(admin) {
13122
13889
  }
13123
13890
  return actions;
13124
13891
  }
13892
+ function parseCsv(text) {
13893
+ const source = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
13894
+ const rows = [];
13895
+ let row = [];
13896
+ let field = "";
13897
+ let quoted = false;
13898
+ for (let index = 0; index < source.length; index += 1) {
13899
+ const char = source[index];
13900
+ if (quoted) {
13901
+ if (char === '"') {
13902
+ if (source[index + 1] === '"') {
13903
+ field += '"';
13904
+ index += 1;
13905
+ } else {
13906
+ quoted = false;
13907
+ }
13908
+ } else {
13909
+ field += char;
13910
+ }
13911
+ continue;
13912
+ }
13913
+ if (char === '"') {
13914
+ quoted = true;
13915
+ } else if (char === ",") {
13916
+ row.push(field);
13917
+ field = "";
13918
+ } else if (char === "\n" || char === "\r") {
13919
+ if (char === "\r" && source[index + 1] === "\n") index += 1;
13920
+ row.push(field);
13921
+ rows.push(row);
13922
+ row = [];
13923
+ field = "";
13924
+ } else {
13925
+ field += char;
13926
+ }
13927
+ }
13928
+ if (field !== "" || row.length > 0) {
13929
+ row.push(field);
13930
+ rows.push(row);
13931
+ }
13932
+ const header = rows.shift();
13933
+ if (header === void 0 || header.length === 0) {
13934
+ throw new Error("The file has no header row.");
13935
+ }
13936
+ const keys = header.map((name) => name.trim());
13937
+ return rows.filter((entry) => entry.some((value) => value.trim() !== "")).map(
13938
+ (entry) => Object.fromEntries(keys.map((key, position) => [key, entry[position] ?? ""]))
13939
+ );
13940
+ }
13125
13941
  function exportValue(value) {
13126
13942
  if (value instanceof Date) return value.toISOString();
13127
13943
  if (typeof value === "bigint") return value.toString();
@@ -15154,6 +15970,6 @@ async function withTestDatabase(models, fn) {
15154
15970
  }
15155
15971
  }
15156
15972
 
15157
- export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminPermission, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminLens, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, metricCard, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseFormBody, partitionTotal, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, trendDirection, trendPercent, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
15973
+ export { ADMIN_CSS, ActivationService, AdminJsonSite, AdminModel, AdminPermission, AdminSessionStore, AdminSite, AppException, AttemptThrottle, AuditAction, BaseAuditLogModel, BaseController, BaseModel, BaseOAuthClient, BaseOutboxModel, BaseService, BaseUserModel, BaseUserRefreshTokenModel, BaseUserTokenModel, CEP_PATTERN, CNPJ_PATTERN, CPF_PATTERN, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, CircuitOpenError, CompositeFeatureFlagBackend, ConflictException, DEFAULT_DOCS_FAVICON, DEFAULT_LOCALE, EmailProvider, EmailUtils, EnvFeatureFlagBackend, EventStream, ExpiredTokenException, FeatureFlags, ForbiddenException, GitHubOAuthClient, GoogleOAuthClient, GracefulShutdown, HTTPClient, HTTP_500_LOG_FILE, HTTP_500_MARKER, HttpMetrics, IDEMPOTENCY_HEADER, InvalidTokenException, JSONLogger, JWTUtils, LEVEL_LOG_FILES, LocalUploadStorage, MemoryBroker, MemoryCacheManager, MemoryFeatureFlagBackend, MemoryIdempotencyStore, MemoryRateLimitStore, MemorySessionStore, MemoryThrottleBackend, MessageCatalog, MessagingHub, MetricsUtils, MfaService, MultipartLimitError, NotFoundException, OAuthError, OIDCProvider, OutboxRelay, OutboxStatus, PHONE_BR_PATTERN, PasswordResetService, REDOC_CDN_URL, REQUEST_ID_HEADER, RabbitBroker, RedisCacheManager, RedisIdempotencyStore, RedisRateLimitStore, RedisSSEBroker, RedisSessionStore, Region, RetryPolicy, S3UploadStorage, SSEBroker, ServerSentEvent, SessionService, TOTPHelper, TaskManager, TelegramProvider, TenantScopedRepository, TooManyRequestsException, TwilioSmsProvider, UF, UnauthorizedException, UserAuthService, UserModelAuthBackend, UserTokenPurpose, ValidationException, WebPushDispatcher, WebPushError, WebPushGoneError, WebSocketHub, WebhookSignatureVerifier, WhatsAppProvider, activationSchema, addLogSink, adminAction, adminColumns, adminInline, adminLens, adminThemeCss, attachWebSocketHub, authResponseSchema, authSettingsShape, backupDatabase, bearerToken, bodySizeLimitMiddleware, broadcastText, buildContentDisposition, buildFormFields, buildPaginationLinkHeader, cached2 as cached, cepField, citiesByUf, cnpjField, coerceFlag, configureFileLogging, configureLogging, cpfField, cpfOrCnpjField, createApp, createOpenApiRegistry, createTestDatabase, createdByColumn, csrfMiddleware, csrfTokenMatches, cursorPaginationFilterSchema, cursorPaginationSchema, decodeCursor, defaultMessageCatalog, defineEnum, deletedAtColumn, diffSnapshots, emailSettingsShape, encodeCursor, envList, escapeHtml, filterForColumn, foreignKeyFields, foreignKeyLabel, foreignKeyTable, formatCellValue, formatFieldValue, generateCsrfToken, generateOAuthState, generateOpaqueToken, generateOpenApiDocument, getAuth, getClientIp, getConditions, getPaginationConditions, getRequestId, getState, groupInlineSubmission, hashOpaqueToken, humanizeField, idempotencyMiddleware, inboundMessageSchema, isColumnOptional, isMultipart, isSearchableColumn, isValidCep, isValidCity, isValidCnpj, isValidCpf, isValidCpfCnpj, isValidPhoneBr, isValidUf, jwtSettingsShape, keyByHeader, keyByIp, keyByJwtClaim, keyByJwtSubject, listStates, logEntrySchema, logSettingsShape, loginSchema, makeAdminJsonRouter, makeAdminRouter, makeAppExceptionHandler, makeAuthRouter, makeFlagGuard, makeHealthRouter, makeJwtAuthMiddleware, makeLogsRouter, makeMetricsRouter, makeSessionMiddleware, makeToolSpecRouter, makeTwilioWebhookRouter, makeUnhandledExceptionHandler, makeWhatsAppWebhookRouter, metricCard, mfaChallengeSchema, mfaCodeSchema, mfaEnrollResponseSchema, minioSettingsShape, modifyDict, mountOpenApiJson, mountRedoc, mountSwaggerUi, normalizeCep, normalizeCnpj, normalizeCpf, normalizeCpfCnpj, normalizePhoneBr, normalizeUf, notFoundHandler, onlyDigits, paginationFilterSchema, paginationSchema, parseAcceptLanguage, parseCookies, parseCsv, parseFormBody, parseMultipart, partitionTotal, passwordResetConfirmSchema, passwordResetRequestSchema, phoneBrField, prometheusMiddleware, rabbitmqSettingsShape, rateLimitMiddleware, redisSettingsShape, refreshSchema, registerExceptionHandlers, renderAuthResultPage, renderDashboardPage, renderDetailPage, renderFormPage, renderImportPage, renderLayout, renderListPage, renderLoginPage, renderMfaPage, renderPasswordResetFormPage, requestIdMiddleware, requestTracingMiddleware, requireRoles, resolveAdminTheme, resolveDownloadPath, resolveRedocBundle, runServer, runWithRequestContext, sendBytesDownload, sendFileDownload, sessionCookie, sessionSettingsShape, setRequestId, signupSchema, snapshot, sseResponse, statesByRegion, syncFilterSchema, syncPaginationSchema, tableNameFor, toUtc, tokenFromUrl, tokenPairSchema, tokenSettingsShape, trendDirection, trendPercent, ufField, updatedByColumn, uploadSettingsShape, userPublicSchema, utcnow, validateTwilioSignature, verifyOpaqueToken, webPushKeysSchema, webPushPayloadSchema, webPushSettingsShape, webPushSubscriptionSchema, webSocketSettingsShape, widgetForColumn, withTestDatabase, wrapWithSlowQueryLog, wsEnvelopeSchema };
15158
15974
  //# sourceMappingURL=index.js.map
15159
15975
  //# sourceMappingURL=index.js.map