@easy-cms/core 1.30.1 → 1.31.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.
@@ -161,6 +161,7 @@ var AUDIT_LOGS = "audit-logs";
161
161
  var OUTBOX = "outbox";
162
162
  var RATE_LIMITS = "rate-limits";
163
163
  var BULK_OPERATIONS = "bulk-operations";
164
+ var SAVED_VIEWS = "saved-views";
164
165
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
166
  SESSIONS,
166
167
  LOGIN_ATTEMPTS,
@@ -175,7 +176,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
175
176
  AUDIT_LOGS,
176
177
  OUTBOX,
177
178
  RATE_LIMITS,
178
- BULK_OPERATIONS
179
+ BULK_OPERATIONS,
180
+ SAVED_VIEWS
179
181
  ]);
180
182
  var DEFAULT_ROLES = ["admin", "editor"];
181
183
  var CREATED_BY_FIELD = {
@@ -563,6 +565,22 @@ var bulkOperationsCollection = {
563
565
  { name: "finishedAt", type: "text" }
564
566
  ]
565
567
  };
568
+ var savedViewsCollection = {
569
+ slug: SAVED_VIEWS,
570
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
571
+ fields: [
572
+ { name: "collection", type: "text", required: true, index: true },
573
+ { name: "user", type: "text", required: true, index: true },
574
+ // view | default
575
+ { name: "kind", type: "text", required: true },
576
+ { name: "name", type: "text" },
577
+ { name: "shared", type: "boolean" },
578
+ // `{ query, columns?, pageSize?, density? }`: the list's URL query, and what the browser kept.
579
+ { name: "state", type: "json" },
580
+ // For `default`: a view's id, or `config:<id>` for one of `admin.views`.
581
+ { name: "target", type: "text" }
582
+ ]
583
+ };
566
584
  var webhookDeliveriesCollection = {
567
585
  slug: WEBHOOK_DELIVERIES,
568
586
  access: { read: nobody, create: nobody, update: nobody, delete: nobody },
@@ -705,6 +723,15 @@ var emailDeliveriesCollection = {
705
723
  // src/audit.ts
706
724
  import { AsyncLocalStorage } from "async_hooks";
707
725
  import { createHmac as createHmac2 } from "crypto";
726
+
727
+ // src/csv.ts
728
+ function csvCell(value) {
729
+ const s = value === null || value === void 0 ? "" : typeof value === "string" ? value : JSON.stringify(value);
730
+ const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
731
+ return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
732
+ }
733
+
734
+ // src/audit.ts
708
735
  var DEFAULT_AUDIT_KEEP = 365;
709
736
  var DEFAULT_FAILED_LOGINS = 20;
710
737
  var MAX_VALUE = 500;
@@ -877,11 +904,7 @@ var AuditLog = class {
877
904
  limit: CSV_LIMIT,
878
905
  page: 1
879
906
  });
880
- const cell = (value) => {
881
- const s = value === null || value === void 0 ? "" : typeof value === "string" ? value : JSON.stringify(value);
882
- const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
883
- return /[",\n\r]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
884
- };
907
+ const cell = csvCell;
885
908
  const lines = [
886
909
  ["at", "action", "target", "doc", "title", "actor", "via", "ip", "changes", "detail"].join(
887
910
  ","
@@ -1304,9 +1327,9 @@ var esc = (value) => value.replace(
1304
1327
  (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]
1305
1328
  );
1306
1329
  function email(subject, lines, button) {
1307
- const text3 = [...lines, ...button ? ["", button.url] : []].join("\n");
1330
+ const text4 = [...lines, ...button ? ["", button.url] : []].join("\n");
1308
1331
  const html = `<div style="font-family:system-ui,sans-serif;font-size:15px;line-height:1.5;color:#1f2328">${lines.map((line) => `<p>${esc(line)}</p>`).join("")}${button ? `<p><a href="${esc(button.url)}" style="display:inline-block;padding:10px 18px;border-radius:8px;background:#2f6f5e;color:#fff;text-decoration:none;font-weight:600">${esc(button.label)}</a></p><p style="font-size:13px;color:#656d76">${esc(button.url)}</p>` : ""}</div>`;
1309
- return { subject, text: text3, html };
1332
+ return { subject, text: text4, html };
1310
1333
  }
1311
1334
  var hours = (expiresAt) => Math.max(1, Math.round(((expiresAt?.getTime() ?? Date.now()) - Date.now()) / 36e5));
1312
1335
  var days = (expiresAt) => Math.max(1, Math.round(hours(expiresAt) / 24));
@@ -2597,8 +2620,8 @@ function itemsOf(field, n) {
2597
2620
  return `${n} ${unit}${n === 1 ? "" : "s"}`;
2598
2621
  }
2599
2622
  var isEmpty = (value) => value === void 0 || value === null || value === "";
2600
- function slugify(text3) {
2601
- return text3.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
2623
+ function slugify(text4) {
2624
+ return text4.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
2602
2625
  }
2603
2626
  function applyDefaults(fields, data, localization = null) {
2604
2627
  const result = { ...data };
@@ -2907,7 +2930,7 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2907
2930
  return fail(`must have at least ${field.minRows} rows`);
2908
2931
  if (field.maxRows !== void 0 && raw.length > field.maxRows)
2909
2932
  return fail(`must have at most ${field.maxRows} rows`);
2910
- const rows = [];
2933
+ const rows2 = [];
2911
2934
  for (const [i, row] of raw.entries()) {
2912
2935
  if (!isPlainObject(row)) {
2913
2936
  errors.push({ field: `${path}.${i}`, message: "must be an object" });
@@ -2917,9 +2940,9 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
2917
2940
  errors.push(...sub.errors);
2918
2941
  references2.push(...sub.references);
2919
2942
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
2920
- rows.push({ id, ...sub.data });
2943
+ rows2.push({ id, ...sub.data });
2921
2944
  }
2922
- return rows;
2945
+ return rows2;
2923
2946
  }
2924
2947
  case "blocks":
2925
2948
  return normalizeBlocks(field, raw, path, options, fail, errors, references2);
@@ -2933,7 +2956,7 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
2933
2956
  return fail(`must have at least ${field.minRows} blocks`);
2934
2957
  if (field.maxRows !== void 0 && raw.length > field.maxRows)
2935
2958
  return fail(`must have at most ${field.maxRows} blocks`);
2936
- const rows = [];
2959
+ const rows2 = [];
2937
2960
  for (const [i, row] of raw.entries()) {
2938
2961
  const fields = rowFields(field, row);
2939
2962
  if (!isPlainObject(row) || !fields) {
@@ -2947,9 +2970,9 @@ async function normalizeBlocks(field, raw, path, options, fail, errors, referenc
2947
2970
  errors.push(...sub.errors);
2948
2971
  references2.push(...sub.references);
2949
2972
  const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
2950
- rows.push({ id, blockType: row.blockType, ...sub.data });
2973
+ rows2.push({ id, blockType: row.blockType, ...sub.data });
2951
2974
  }
2952
- return rows;
2975
+ return rows2;
2953
2976
  }
2954
2977
  function normalizeSelect(field, raw, fail) {
2955
2978
  const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
@@ -3198,11 +3221,11 @@ async function copyDatabase(source, target, options = {}) {
3198
3221
  for (const table of tables) {
3199
3222
  let copied = 0;
3200
3223
  for (; ; ) {
3201
- const rows = await from.read(table, copied, BATCH);
3202
- if (rows.length === 0) break;
3203
- await to.write(table, rows);
3204
- copied += rows.length;
3205
- if (rows.length < BATCH) break;
3224
+ const rows2 = await from.read(table, copied, BATCH);
3225
+ if (rows2.length === 0) break;
3226
+ await to.write(table, rows2);
3227
+ copied += rows2.length;
3228
+ if (rows2.length < BATCH) break;
3206
3229
  }
3207
3230
  total += copied;
3208
3231
  options.onTable?.({ table, rows: copied });
@@ -3413,26 +3436,26 @@ async function writeBackupFile(cms, file) {
3413
3436
  });
3414
3437
  }
3415
3438
  }
3416
- var slug = (text3) => text3.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
3439
+ var slug = (text4) => text4.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "easy-cms";
3417
3440
  function filenameFor(cms, date) {
3418
3441
  const pad = (n) => String(n).padStart(2, "0");
3419
3442
  const stamp = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}`;
3420
3443
  return `${slug(cms.config.admin.brand.name || "easy-cms")}-${stamp}.db.gz`;
3421
3444
  }
3422
3445
  function toAdmin(row) {
3423
- const text3 = (value) => typeof value === "string" && value !== "" ? value : null;
3446
+ const text4 = (value) => typeof value === "string" && value !== "" ? value : null;
3424
3447
  return {
3425
3448
  id: row.id,
3426
3449
  state: ["pending", "running", "done", "failed"].includes(row.state) ? row.state : "failed",
3427
3450
  trigger: row.trigger === "scheduled" ? "scheduled" : "manual",
3428
- filename: text3(row.filename),
3451
+ filename: text4(row.filename),
3429
3452
  size: typeof row.size === "number" ? row.size : null,
3430
- startedAt: text3(row.startedAt),
3431
- finishedAt: text3(row.finishedAt),
3432
- error: text3(row.error),
3433
- author: text3(row.author),
3434
- downloadedBy: text3(row.downloadedBy),
3435
- downloadedAt: text3(row.downloadedAt)
3453
+ startedAt: text4(row.startedAt),
3454
+ finishedAt: text4(row.finishedAt),
3455
+ error: text4(row.error),
3456
+ author: text4(row.author),
3457
+ downloadedBy: text4(row.downloadedBy),
3458
+ downloadedAt: text4(row.downloadedAt)
3436
3459
  };
3437
3460
  }
3438
3461
  async function update(cms, row, data) {
@@ -3771,10 +3794,10 @@ var MediaFolders = class {
3771
3794
  async rows() {
3772
3795
  if (this.cache && Date.now() - this.cache.at < TTL) return this.cache.rows;
3773
3796
  const found = await this.db.find({ collection: MEDIA_FOLDERS, sort: [], limit: 0, page: 1 });
3774
- const rows = /* @__PURE__ */ new Map();
3797
+ const rows2 = /* @__PURE__ */ new Map();
3775
3798
  for (const doc of found.docs) {
3776
3799
  const permissions = doc.permissions;
3777
- rows.set(String(doc.id), {
3800
+ rows2.set(String(doc.id), {
3778
3801
  id: doc.id,
3779
3802
  parent: doc.parent ?? null,
3780
3803
  name: String(doc.name ?? ""),
@@ -3784,31 +3807,31 @@ var MediaFolders = class {
3784
3807
  doc
3785
3808
  });
3786
3809
  }
3787
- this.cache = { at: Date.now(), rows };
3788
- return rows;
3810
+ this.cache = { at: Date.now(), rows: rows2 };
3811
+ return rows2;
3789
3812
  }
3790
3813
  /** A role's level in a folder (`null`: the top level): the nearest permissions up the tree. */
3791
- levelIn(rows, role, id) {
3814
+ levelIn(rows2, role, id) {
3792
3815
  const seen = /* @__PURE__ */ new Set();
3793
- let current = id === null ? void 0 : rows.get(String(id));
3816
+ let current = id === null ? void 0 : rows2.get(String(id));
3794
3817
  while (current && !seen.has(String(current.id))) {
3795
3818
  seen.add(String(current.id));
3796
3819
  if (current.permissions) {
3797
3820
  const level = current.permissions[role];
3798
3821
  return level && level in RANK ? level : "none";
3799
3822
  }
3800
- current = current.parent === null ? void 0 : rows.get(String(current.parent));
3823
+ current = current.parent === null ? void 0 : rows2.get(String(current.parent));
3801
3824
  }
3802
3825
  return "manage";
3803
3826
  }
3804
3827
  /** Whether files in a folder (`null`: the top level) are private: it or a folder above is. */
3805
- privateIn(rows, id) {
3828
+ privateIn(rows2, id) {
3806
3829
  const seen = /* @__PURE__ */ new Set();
3807
- let current = id === null ? void 0 : rows.get(String(id));
3830
+ let current = id === null ? void 0 : rows2.get(String(id));
3808
3831
  while (current && !seen.has(String(current.id))) {
3809
3832
  if (current.private) return true;
3810
3833
  seen.add(String(current.id));
3811
- current = current.parent === null ? void 0 : rows.get(String(current.parent));
3834
+ current = current.parent === null ? void 0 : rows2.get(String(current.parent));
3812
3835
  }
3813
3836
  return false;
3814
3837
  }
@@ -3818,26 +3841,26 @@ var MediaFolders = class {
3818
3841
  return this.privateIn(await this.rows(), id);
3819
3842
  }
3820
3843
  /** The folder and every folder inside it. */
3821
- within(rows, ids) {
3844
+ within(rows2, ids) {
3822
3845
  const out = new Set(ids.map(String));
3823
3846
  let grew = true;
3824
3847
  while (grew) {
3825
3848
  grew = false;
3826
- for (const row of rows.values())
3849
+ for (const row of rows2.values())
3827
3850
  if (row.parent !== null && out.has(String(row.parent)) && !out.has(String(row.id))) {
3828
3851
  out.add(String(row.id));
3829
3852
  grew = true;
3830
3853
  }
3831
3854
  }
3832
- return [...rows.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
3855
+ return [...rows2.values()].filter((r) => out.has(String(r.id))).map((r) => r.id);
3833
3856
  }
3834
3857
  /**
3835
3858
  * Folders whose privacy follows this one: it and those inside, with their privacy now.
3836
3859
  * @internal
3837
3860
  */
3838
3861
  async subtree(id) {
3839
- const rows = await this.fresh();
3840
- return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
3862
+ const rows2 = await this.fresh();
3863
+ return this.within(rows2, [id]).map((f) => ({ id: f, private: this.privateIn(rows2, f) }));
3841
3864
  }
3842
3865
  /**
3843
3866
  * The privacy each folder of a subtree would have after a change to its top folder: made
@@ -3845,15 +3868,15 @@ var MediaFolders = class {
3845
3868
  * @internal
3846
3869
  */
3847
3870
  async privacyAfter(id, change) {
3848
- const rows = new Map(await this.fresh());
3849
- const row = rows.get(String(id));
3871
+ const rows2 = new Map(await this.fresh());
3872
+ const row = rows2.get(String(id));
3850
3873
  if (!row) return [];
3851
- rows.set(String(id), {
3874
+ rows2.set(String(id), {
3852
3875
  ...row,
3853
3876
  ...change.private !== void 0 ? { private: change.private } : {},
3854
3877
  ...change.parent !== void 0 ? { parent: change.parent } : {}
3855
3878
  });
3856
- return this.within(rows, [id]).map((f) => ({ id: f, private: this.privateIn(rows, f) }));
3879
+ return this.within(rows2, [id]).map((f) => ({ id: f, private: this.privateIn(rows2, f) }));
3857
3880
  }
3858
3881
  async keyed(key, scope = {}, create = true) {
3859
3882
  const find = async () => [...(await this.rows()).values()].find((r) => r.key === key && inScope(r.doc, scope));
@@ -3891,9 +3914,9 @@ var MediaFolders = class {
3891
3914
  /** Whether a file in `folder` is in the folder with this key, or below it. @internal */
3892
3915
  async inKeyed(key, folder, scope = {}) {
3893
3916
  if (folder === null) return false;
3894
- const rows = await this.rows();
3895
- const root = [...rows.values()].find((r) => r.key === key && inScope(r.doc, scope));
3896
- return !!root && this.within(rows, [root.id]).some((id) => String(id) === String(folder));
3917
+ const rows2 = await this.rows();
3918
+ const root = [...rows2.values()].find((r) => r.key === key && inScope(r.doc, scope));
3919
+ return !!root && this.within(rows2, [root.id]).some((id) => String(id) === String(folder));
3897
3920
  }
3898
3921
  /** The user's level in a folder (`null`: the top level). */
3899
3922
  async level(user, id) {
@@ -3916,11 +3939,11 @@ var MediaFolders = class {
3916
3939
  if (!this.applies(user)) return true;
3917
3940
  if (operation === "create") return true;
3918
3941
  const need = RANK[needed(collection2, operation)];
3919
- const rows = await this.rows();
3942
+ const rows2 = await this.rows();
3920
3943
  const allowed2 = [];
3921
3944
  let all = true;
3922
- for (const row of rows.values()) {
3923
- if (RANK[this.levelIn(rows, user.role, row.id)] >= need) allowed2.push(row.id);
3945
+ for (const row of rows2.values()) {
3946
+ if (RANK[this.levelIn(rows2, user.role, row.id)] >= need) allowed2.push(row.id);
3924
3947
  else all = false;
3925
3948
  }
3926
3949
  if (all) return true;
@@ -3948,12 +3971,12 @@ var MediaFolders = class {
3948
3971
  * @internal
3949
3972
  */
3950
3973
  async validate(data, self) {
3951
- const rows = await this.fresh();
3974
+ const rows2 = await this.fresh();
3952
3975
  const name = typeof data.name === "string" ? data.name.trim() : "";
3953
3976
  const parent = data.parent ?? null;
3954
3977
  const errors = [];
3955
3978
  if (parent !== null && self !== void 0) {
3956
- let current = rows.get(String(parent));
3979
+ let current = rows2.get(String(parent));
3957
3980
  const seen = /* @__PURE__ */ new Set();
3958
3981
  while (current && !seen.has(String(current.id))) {
3959
3982
  if (String(current.id) === String(self)) {
@@ -3961,10 +3984,10 @@ var MediaFolders = class {
3961
3984
  break;
3962
3985
  }
3963
3986
  seen.add(String(current.id));
3964
- current = current.parent === null ? void 0 : rows.get(String(current.parent));
3987
+ current = current.parent === null ? void 0 : rows2.get(String(current.parent));
3965
3988
  }
3966
3989
  }
3967
- const taken = [...rows.values()].some(
3990
+ const taken = [...rows2.values()].some(
3968
3991
  (row) => String(row.id) !== String(self) && String(row.parent) === String(parent) && row.name.trim().toLocaleLowerCase() === name.toLocaleLowerCase()
3969
3992
  );
3970
3993
  if (name && taken) errors.push({ field: "name", message: "is already used in this folder" });
@@ -4218,7 +4241,7 @@ var VersionStore = class {
4218
4241
  async latest(parent, docs) {
4219
4242
  const result = /* @__PURE__ */ new Map();
4220
4243
  if (docs.length === 0) return result;
4221
- const rows = await this.db.find({
4244
+ const rows2 = await this.db.find({
4222
4245
  collection: VERSIONS,
4223
4246
  where: {
4224
4247
  and: [
@@ -4231,7 +4254,7 @@ var VersionStore = class {
4231
4254
  limit: 0,
4232
4255
  page: 1
4233
4256
  });
4234
- for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
4257
+ for (const row of rows2.docs) result.set(String(row.doc), this.toVersion(row));
4235
4258
  return result;
4236
4259
  }
4237
4260
  async list(parent, doc, limit, page) {
@@ -4256,24 +4279,24 @@ var VersionStore = class {
4256
4279
  return row ? this.toVersion(row) : null;
4257
4280
  }
4258
4281
  async deleteAll(parent, doc) {
4259
- const rows = await this.db.find({
4282
+ const rows2 = await this.db.find({
4260
4283
  collection: VERSIONS,
4261
4284
  where: { and: this.of(parent, doc) },
4262
4285
  sort: [],
4263
4286
  limit: 0,
4264
4287
  page: 1
4265
4288
  });
4266
- for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
4289
+ for (const row of rows2.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
4267
4290
  }
4268
4291
  async prune(parent, doc, max) {
4269
- const rows = await this.db.find({
4292
+ const rows2 = await this.db.find({
4270
4293
  collection: VERSIONS,
4271
4294
  where: { and: this.of(parent, doc) },
4272
4295
  sort: ["-id"],
4273
4296
  limit: 0,
4274
4297
  page: 1
4275
4298
  });
4276
- for (const row of rows.docs.slice(max)) {
4299
+ for (const row of rows2.docs.slice(max)) {
4277
4300
  await this.db.delete({ collection: VERSIONS, id: row.id });
4278
4301
  }
4279
4302
  }
@@ -4847,12 +4870,12 @@ function toLocaleMaps(fields, input, current, locale) {
4847
4870
  locale
4848
4871
  );
4849
4872
  } else if (hasRows(field) && Array.isArray(value)) {
4850
- const rows = new Map(
4873
+ const rows2 = new Map(
4851
4874
  (Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
4852
4875
  );
4853
4876
  result[field.name] = value.map((row) => {
4854
4877
  const fields2 = rowFields(field, row);
4855
- return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows.get(String(row.id)) ?? {}, locale) : row;
4878
+ return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows2.get(String(row.id)) ?? {}, locale) : row;
4856
4879
  });
4857
4880
  }
4858
4881
  }
@@ -5272,13 +5295,13 @@ function embeddedIPv4(address) {
5272
5295
  return bytes ? bytes.slice(12).join(".") : void 0;
5273
5296
  }
5274
5297
  function ipv6Bytes(address) {
5275
- let text3 = address;
5276
- const tail = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text3);
5298
+ let text4 = address;
5299
+ const tail = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(text4);
5277
5300
  if (tail) {
5278
5301
  const [a, b, c, d] = tail.slice(1).map(Number);
5279
- text3 = `${text3.slice(0, tail.index)}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
5302
+ text4 = `${text4.slice(0, tail.index)}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
5280
5303
  }
5281
- const [head = "", rest] = text3.split("::");
5304
+ const [head = "", rest] = text4.split("::");
5282
5305
  const left = head ? head.split(":") : [];
5283
5306
  const right = rest === void 0 ? [] : rest ? rest.split(":") : [];
5284
5307
  const groups = rest === void 0 ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
@@ -5476,7 +5499,7 @@ var GLOBAL = keys(
5476
5499
  );
5477
5500
  var CONTAINER_ADMIN = keys("icon order sidebar layout group");
5478
5501
  var COLLECTION_ADMIN = keys(
5479
- "icon order sidebar layout group editIn badge count empty list calendar ownerField confirmDelete"
5502
+ "icon order sidebar layout group editIn badge count empty list calendar ownerField confirmDelete views export"
5480
5503
  );
5481
5504
  var FIELD_ADMIN = keys(
5482
5505
  "description width condition component after cell column allowCreate initialValue position filter"
@@ -5747,26 +5770,26 @@ function fieldsText(fields, data) {
5747
5770
  }
5748
5771
  function richText(node) {
5749
5772
  if (!node || typeof node !== "object") return "";
5750
- const { text: text3, content } = node;
5751
- if (typeof text3 === "string") return text3;
5773
+ const { text: text4, content } = node;
5774
+ if (typeof text4 === "string") return text4;
5752
5775
  if (!Array.isArray(content)) return "";
5753
5776
  const inline = content.some((child) => typeof child?.text === "string");
5754
5777
  return content.map(richText).filter(Boolean).join(inline ? "" : "\n");
5755
5778
  }
5756
5779
  function searchText(fields, doc, paths) {
5757
- const text3 = {};
5780
+ const text4 = {};
5758
5781
  for (const path of paths) {
5759
5782
  const field = fieldAt(fields, path);
5760
5783
  let value = doc;
5761
5784
  for (const name of path.split("."))
5762
5785
  value = value && typeof value === "object" ? value[name] : void 0;
5763
- text3[path] = field ? textOf(field, value) : "";
5786
+ text4[path] = field ? textOf(field, value) : "";
5764
5787
  }
5765
- return text3;
5788
+ return text4;
5766
5789
  }
5767
5790
  var segmenter = new Intl.Segmenter(void 0, { granularity: "word" });
5768
- function words(text3) {
5769
- return [...segmenter.segment(text3.normalize("NFC").toLowerCase())].filter((s) => s.isWordLike).map((s) => s.segment);
5791
+ function words(text4) {
5792
+ return [...segmenter.segment(text4.normalize("NFC").toLowerCase())].filter((s) => s.isWordLike).map((s) => s.segment);
5770
5793
  }
5771
5794
  function memorySearch() {
5772
5795
  const entries = /* @__PURE__ */ new Map();
@@ -7207,7 +7230,26 @@ function validateLayout(layout, container, path, add) {
7207
7230
  nodes(layout, path, true);
7208
7231
  }
7209
7232
  function validateCollectionAdmin(admin, path, add) {
7210
- const { badge, count, empty } = admin;
7233
+ const { badge, count, empty, views } = admin;
7234
+ if (views !== void 0) {
7235
+ if (!Array.isArray(views)) add(`${path}.views`, "must be an array");
7236
+ const ids = /* @__PURE__ */ new Set();
7237
+ for (const [i, raw] of (Array.isArray(views) ? views : []).entries()) {
7238
+ const view = raw ?? {};
7239
+ const at = `${path}.views[${i}]`;
7240
+ if (typeof view.id !== "string" || !/^[a-z0-9][a-z0-9_-]*$/.test(view.id))
7241
+ add(`${at}.id`, 'must be lowercase letters, digits, "-" or "_"', "e.g. id: 'to-review'");
7242
+ else if (ids.has(view.id)) add(`${at}.id`, `duplicate view "${view.id}"`);
7243
+ else ids.add(view.id);
7244
+ if (!isLabel(view.label)) add(`${at}.label`, "must be a string or { en, th }");
7245
+ if (view.where !== void 0 && (typeof view.where !== "object" || view.where === null || Array.isArray(view.where)))
7246
+ add(`${at}.where`, "must be a where query", "e.g. { status: { equals: 'draft' } }");
7247
+ if (view.pageSize !== void 0 && ![10, 25, 50, 100].includes(view.pageSize))
7248
+ add(`${at}.pageSize`, "must be 10, 25, 50 or 100");
7249
+ if (view.density !== void 0 && !["comfortable", "compact"].includes(view.density))
7250
+ add(`${at}.density`, "must be 'comfortable' or 'compact'");
7251
+ }
7252
+ }
7211
7253
  if (badge !== void 0) {
7212
7254
  const b = badge ?? {};
7213
7255
  if (typeof b.where !== "object" || b.where === null || Array.isArray(b.where))
@@ -7217,6 +7259,8 @@ function validateCollectionAdmin(admin, path, add) {
7217
7259
  }
7218
7260
  if (count !== void 0 && typeof count !== "boolean")
7219
7261
  add(`${path}.count`, "must be true or false");
7262
+ if (admin.export !== void 0 && typeof admin.export !== "boolean")
7263
+ add(`${path}.export`, "must be true or false");
7220
7264
  if (empty !== void 0) {
7221
7265
  const e = empty ?? {};
7222
7266
  if (e.description !== void 0 && !isLabel(e.description))
@@ -7981,6 +8025,7 @@ async function resolveConfig(input) {
7981
8025
  ...config.rateLimit ? [rateLimitsCollection] : [],
7982
8026
  databaseBackupsCollection,
7983
8027
  bulkOperationsCollection,
8028
+ savedViewsCollection,
7984
8029
  ...config.auth?.rbac ? [rolesCollection] : [],
7985
8030
  ...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : [],
7986
8031
  ...config.audit ? [auditLogsCollection] : []
@@ -8101,7 +8146,7 @@ function cacheableResponse(request, body, status, headers, times, tags) {
8101
8146
  }
8102
8147
 
8103
8148
  // src/version.ts
8104
- var VERSION = true ? "1.30.1" : "0.0.0-dev";
8149
+ var VERSION = true ? "1.31.0" : "0.0.0-dev";
8105
8150
 
8106
8151
  // src/telemetry.ts
8107
8152
  var DB_CALLS = /* @__PURE__ */ new Set([
@@ -9801,12 +9846,12 @@ var EasyCMS = class {
9801
9846
  if (asked !== void 0 && localization && !localization.locales.includes(asked))
9802
9847
  throw new QueryError(`Unknown locale "${asked}"`);
9803
9848
  const locale = localization ? asked ?? localization.defaultLocale ?? null : null;
9804
- const text3 = query.trim().slice(0, 200);
9849
+ const text4 = query.trim().slice(0, 200);
9805
9850
  const empty = { hits: [], total: 0, page, limit, hasNextPage: false };
9806
- if (!text3 || collections.length === 0) return empty;
9851
+ if (!text4 || collections.length === 0) return empty;
9807
9852
  const { collections: _, limit: __, page: ___, ...read2 } = options;
9808
9853
  const { hits, total } = await index.search(
9809
- { query: text3, collections, locale, limit, offset: (page - 1) * limit },
9854
+ { query: text4, collections, locale, limit, offset: (page - 1) * limit },
9810
9855
  async (collection2, ids) => (await this.find(collection2, {
9811
9856
  ...read2,
9812
9857
  ...locale ? { locale } : {},
@@ -9960,13 +10005,13 @@ var EasyCMS = class {
9960
10005
  const { jobs } = this.config;
9961
10006
  if (jobs.length === 0) return { ran: 0, failed: 0 };
9962
10007
  const { docs } = await this.db.find({ collection: JOB_RUNS, sort: ["name"], limit: 0, page: 1 });
9963
- const rows = new Map(docs.map((row) => [String(row.name), row]));
10008
+ const rows2 = new Map(docs.map((row) => [String(row.name), row]));
9964
10009
  let legacy;
9965
10010
  const stamp = now.toISOString();
9966
10011
  let ran = 0;
9967
10012
  let failed = 0;
9968
10013
  for (const job of jobs) {
9969
- const fresh = rows.has(job.name) ? await this.db.findById({ collection: JOB_RUNS, id: rows.get(job.name)?.id }) : null;
10014
+ const fresh = rows2.has(job.name) ? await this.db.findById({ collection: JOB_RUNS, id: rows2.get(job.name)?.id }) : null;
9970
10015
  const row = fresh ?? void 0;
9971
10016
  if (row?.pausedAt) continue;
9972
10017
  const schedule = jobSchedule(job, row);
@@ -10350,7 +10395,7 @@ var EasyCMS = class {
10350
10395
  ...options.from !== void 0 ? { gte: options.from } : {},
10351
10396
  ...options.to !== void 0 ? { lt: options.to } : {}
10352
10397
  };
10353
- const rows = await this.db.find({
10398
+ const rows2 = await this.db.find({
10354
10399
  collection: SCHEDULED_JOBS,
10355
10400
  where: {
10356
10401
  and: [
@@ -10385,7 +10430,7 @@ var EasyCMS = class {
10385
10430
  return result;
10386
10431
  };
10387
10432
  const jobs = [];
10388
- for (const row of rows.docs) {
10433
+ for (const row of rows2.docs) {
10389
10434
  const parent = String(row.parent);
10390
10435
  if (!await may2(parent)) continue;
10391
10436
  const target = parent.startsWith("global:") ? { global: splitGlobalKey(parent.slice("global:".length)).slug } : { collection: parent };
@@ -10395,7 +10440,7 @@ var EasyCMS = class {
10395
10440
  return jobs;
10396
10441
  }
10397
10442
  async pendingJobs(parent, doc) {
10398
- const rows = await this.db.find({
10443
+ const rows2 = await this.db.find({
10399
10444
  collection: SCHEDULED_JOBS,
10400
10445
  where: {
10401
10446
  and: [
@@ -10408,7 +10453,7 @@ var EasyCMS = class {
10408
10453
  limit: 0,
10409
10454
  page: 1
10410
10455
  });
10411
- return rows.docs.map(toJob);
10456
+ return rows2.docs.map(toJob);
10412
10457
  }
10413
10458
  async removeJob(parent, doc, jobId) {
10414
10459
  const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
@@ -11441,8 +11486,8 @@ function hasCalendarPage(config) {
11441
11486
  var text2 = (value) => typeof value === "string" && value !== "" ? value : null;
11442
11487
  async function adminJobs(cms, user) {
11443
11488
  const { config } = cms;
11444
- const rows = has(config, JOB_RUNS) ? (await cms.db.find({ collection: JOB_RUNS, sort: ["name"], limit: 0, page: 1 })).docs : [];
11445
- const byName = new Map(rows.map((row) => [String(row.name), row]));
11489
+ const rows2 = has(config, JOB_RUNS) ? (await cms.db.find({ collection: JOB_RUNS, sort: ["name"], limit: 0, page: 1 })).docs : [];
11490
+ const byName = new Map(rows2.map((row) => [String(row.name), row]));
11446
11491
  const jobs = config.jobs.map((job) => {
11447
11492
  const row = byName.get(job.name);
11448
11493
  const lastRunAt = text2(row?.lastRunAt);
@@ -11816,9 +11861,9 @@ var Roles = class {
11816
11861
  }
11817
11862
  async addMissing() {
11818
11863
  if (!this.enabled) return;
11819
- const rows = await this.rows();
11820
- const first = rows.length === 0;
11821
- const known = new Set(rows.map((r) => String(r.key)));
11864
+ const rows2 = await this.rows();
11865
+ const first = rows2.length === 0;
11866
+ const known = new Set(rows2.map((r) => String(r.key)));
11822
11867
  for (const key of this.config.auth.roles) {
11823
11868
  if (known.has(key)) continue;
11824
11869
  try {
@@ -11876,15 +11921,15 @@ var Roles = class {
11876
11921
  // --- Settings → Roles ----------------------------------------------------
11877
11922
  async list() {
11878
11923
  await this.sync();
11879
- const rows = await this.rows();
11924
+ const rows2 = await this.rows();
11880
11925
  const counts = /* @__PURE__ */ new Map();
11881
- for (const row of rows) {
11926
+ for (const row of rows2) {
11882
11927
  counts.set(
11883
11928
  String(row.key),
11884
11929
  await this.db.count({ collection: USERS, where: { role: { equals: row.key } } })
11885
11930
  );
11886
11931
  }
11887
- const roles = rows.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
11932
+ const roles = rows2.map((row) => this.toRole(row, counts.get(String(row.key)) ?? 0)).sort((a, b) => rank(a, this.config) - rank(b, this.config) || a.key.localeCompare(b.key));
11888
11933
  const decided = roles.filter((r) => r.key !== "admin").map((r) => r.permissions);
11889
11934
  const seen = (group, slug2) => decided.some((p) => p[group]?.[slug2] !== void 0);
11890
11935
  return {
@@ -12261,9 +12306,9 @@ var PER_PAGE = 50;
12261
12306
  var collectionOf = (kind) => kind === "webhook" ? WEBHOOK_DELIVERIES : EMAIL_DELIVERIES;
12262
12307
  var exists = (cms, kind) => cms.config.collections.some((c) => c.slug === collectionOf(kind));
12263
12308
  var isDeliveryKind = (value) => value === "webhook" || value === "email";
12264
- function parse3(text3) {
12309
+ function parse3(text4) {
12265
12310
  try {
12266
- const value = JSON.parse(String(text3 ?? ""));
12311
+ const value = JSON.parse(String(text4 ?? ""));
12267
12312
  return typeof value === "object" && value !== null ? value : {};
12268
12313
  } catch {
12269
12314
  return {};
@@ -12445,7 +12490,7 @@ async function sendTestEmail(cms, user, to, locale) {
12445
12490
  const th = locale === "th" || locale !== "en" && cms.config.admin.locale === "th";
12446
12491
  const time = (/* @__PURE__ */ new Date()).toISOString();
12447
12492
  const subject = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E08\u0E32\u0E01 ${site}` : `Test email from ${site}`;
12448
- const text3 = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 \u2192 \u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E02\u0E2D\u0E07 ${site} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32
12493
+ const text4 = th ? `\u0E2D\u0E35\u0E40\u0E21\u0E25\u0E19\u0E35\u0E49\u0E2A\u0E48\u0E07\u0E08\u0E32\u0E01\u0E2B\u0E19\u0E49\u0E32 \u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32 \u2192 \u0E2D\u0E35\u0E40\u0E21\u0E25 \u0E02\u0E2D\u0E07 ${site} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E17\u0E14\u0E2A\u0E2D\u0E1A\u0E01\u0E32\u0E23\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32
12449
12494
 
12450
12495
  \u0E2A\u0E48\u0E07\u0E42\u0E14\u0E22: ${user.email}
12451
12496
  Adapter: ${adapter.name ?? "custom"}
@@ -12457,7 +12502,7 @@ Adapter: ${adapter.name ?? "custom"}
12457
12502
  From: ${from}
12458
12503
  Time: ${time}`;
12459
12504
  try {
12460
- await adapter.send({ to: address, subject, text: text3, from });
12505
+ await adapter.send({ to: address, subject, text: text4, from });
12461
12506
  return logsOnly(cms) ? { ok: true, logged: true } : { ok: true };
12462
12507
  } catch (error) {
12463
12508
  return { ok: false, error: message(error) };
@@ -12647,6 +12692,8 @@ async function collection(cms, config, user, localized, roles, context) {
12647
12692
  if (config.admin?.empty) result.empty = JSON.parse(JSON.stringify(config.admin.empty));
12648
12693
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
12649
12694
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
12695
+ if (config.admin?.views?.length) result.views = JSON.parse(JSON.stringify(config.admin.views));
12696
+ if (config.admin?.export === false) result.noExport = true;
12650
12697
  if (config.admin?.calendar) result.calendar = { ...config.admin.calendar };
12651
12698
  if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
12652
12699
  const owner = cms.roles.ownerOf(config.slug);
@@ -13097,6 +13144,182 @@ async function adminStatus(cms) {
13097
13144
  };
13098
13145
  }
13099
13146
 
13147
+ // src/rest/export-csv.ts
13148
+ var EXPORT_MAX = 1e5;
13149
+ var STEP = 500;
13150
+ var SYSTEM = {
13151
+ id: "ID",
13152
+ status: { en: "Status", th: "\u0E2A\u0E16\u0E32\u0E19\u0E30" },
13153
+ createdAt: { en: "Created", th: "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E40\u0E21\u0E37\u0E48\u0E2D" },
13154
+ updatedAt: { en: "Updated", th: "\u0E41\u0E01\u0E49\u0E44\u0E02\u0E40\u0E21\u0E37\u0E48\u0E2D" }
13155
+ };
13156
+ var text3 = (label, fallback, lang) => label === void 0 ? fallback : typeof label === "string" ? label : label[lang] ?? label.en ?? Object.values(label)[0] ?? fallback;
13157
+ function isoInZone(value, timeZone) {
13158
+ const date = new Date(value);
13159
+ if (!timeZone || Number.isNaN(date.getTime())) return value;
13160
+ const parts = Object.fromEntries(
13161
+ new Intl.DateTimeFormat("en-CA", {
13162
+ timeZone,
13163
+ year: "numeric",
13164
+ month: "2-digit",
13165
+ day: "2-digit",
13166
+ hour: "2-digit",
13167
+ minute: "2-digit",
13168
+ second: "2-digit",
13169
+ hourCycle: "h23"
13170
+ }).formatToParts(date).map((p) => [p.type, p.value])
13171
+ );
13172
+ const local = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`;
13173
+ const offset = Math.round((Date.parse(`${local}Z`) - date.getTime()) / 6e4);
13174
+ const sign3 = offset < 0 ? "-" : "+";
13175
+ const abs = Math.abs(offset);
13176
+ return `${local}${sign3}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
13177
+ }
13178
+ function cellOf(cms, field, value, key, lang) {
13179
+ if (value === null || value === void 0) return "";
13180
+ const timeZone = cms.config.timeZone;
13181
+ if (!field)
13182
+ return key === "createdAt" || key === "updatedAt" ? isoInZone(String(value), timeZone) : value;
13183
+ const titleOf = (slug2, doc) => {
13184
+ if (!doc || typeof doc !== "object") return String(doc);
13185
+ const d = doc;
13186
+ if (slug2 === "media" && typeof d.url === "string") return d.url;
13187
+ const collection2 = cms.config.collections.find((c) => c.slug === slug2);
13188
+ const title = collection2?.useAsTitle ? d[collection2.useAsTitle] : void 0;
13189
+ return typeof title === "string" && title !== "" ? title : String(d.id ?? "");
13190
+ };
13191
+ switch (field.type) {
13192
+ case "date":
13193
+ return isoInZone(String(value), timeZone);
13194
+ case "boolean":
13195
+ return value ? "true" : "false";
13196
+ case "select": {
13197
+ const label = (v) => {
13198
+ const option = field.options.find((o) => (typeof o === "string" ? o : o.value) === v);
13199
+ return option === void 0 || typeof option === "string" ? String(v) : text3(option.label, String(v), lang);
13200
+ };
13201
+ return Array.isArray(value) ? value.map(label).join("; ") : label(value);
13202
+ }
13203
+ case "richText":
13204
+ return richText(value);
13205
+ case "relationship":
13206
+ case "upload": {
13207
+ const one = (item) => {
13208
+ if (isPolymorphic(field)) {
13209
+ const pair = polymorphicValue(item);
13210
+ if (!pair) return "";
13211
+ const collection2 = cms.config.collections.find((c) => c.slug === pair.relationTo);
13212
+ const name = text3(collection2?.labels?.singular, pair.relationTo, lang);
13213
+ return `${name} \xB7 ${titleOf(pair.relationTo, pair.value)}`;
13214
+ }
13215
+ return titleOf(relationTargets(field)[0], item);
13216
+ };
13217
+ return Array.isArray(value) ? value.map(one).join("; ") : one(value);
13218
+ }
13219
+ default:
13220
+ return value;
13221
+ }
13222
+ }
13223
+ async function columnsOf(cms, config, asked, options) {
13224
+ const rules = await cms.roles.fieldRules(options.user);
13225
+ const read2 = new FieldAccessChecker(
13226
+ "read",
13227
+ { user: options.user, context: options.context },
13228
+ rules
13229
+ );
13230
+ const readable3 = [];
13231
+ for (const field of config.fields)
13232
+ if (!field.hidden && field.name !== "password" && await read2.allows(field))
13233
+ readable3.push(field);
13234
+ const system2 = [...config.drafts ? ["status"] : [], "createdAt", "updatedAt"];
13235
+ const of = (key) => {
13236
+ const field = readable3.find((f) => f.name === key);
13237
+ if (field) return { key, label: field.label ?? key, field };
13238
+ return key in SYSTEM && (key !== "status" || config.drafts) ? { key, label: SYSTEM[key] } : void 0;
13239
+ };
13240
+ const keys2 = asked ?? [...readable3.map((f) => f.name), ...system2];
13241
+ const columns = keys2.filter((k) => k !== "id").map(of).filter((c) => c !== void 0);
13242
+ return [{ key: "id", label: SYSTEM.id }, ...columns];
13243
+ }
13244
+ async function exportCsv(cms, slug2, url, options) {
13245
+ const config = cms.collection(slug2);
13246
+ if (config.admin?.export === false)
13247
+ throw new ForbiddenError("Export is turned off for this collection");
13248
+ const q = url.searchParams;
13249
+ const lang = q.get("lang") ?? "en";
13250
+ const names = q.get("headers") === "names";
13251
+ const columnsParam = q.get("columns");
13252
+ const asked = columnsParam && columnsParam !== "all" ? columnsParam.split(",").filter(Boolean) : null;
13253
+ const ids = q.get("ids")?.split(",").filter(Boolean);
13254
+ const where = ids?.length ? { and: [...options.where ? [options.where] : [], { id: { in: ids } }] } : options.where;
13255
+ const read2 = {
13256
+ user: options.user,
13257
+ context: options.context,
13258
+ overrideAccess: false,
13259
+ draft: options.draft,
13260
+ ...options.locale ? { locale: options.locale } : {},
13261
+ ...options.fallbackLocale === false ? { fallbackLocale: false } : {}
13262
+ };
13263
+ const total = await cms.count(slug2, { ...read2, ...where ? { where } : {}, max: EXPORT_MAX });
13264
+ if (total > EXPORT_MAX)
13265
+ throw new QueryError(
13266
+ `More than ${EXPORT_MAX.toLocaleString("en")} documents match: filter the list further`
13267
+ );
13268
+ const columns = await columnsOf(cms, config, asked, options);
13269
+ const header2 = columns.map((c) => names ? c.key : text3(c.label, c.key, lang));
13270
+ await cms.audit.record({
13271
+ action: "export",
13272
+ target: slug2,
13273
+ user: options.user,
13274
+ detail: { rows: total, columns: columns.map((c) => c.key) }
13275
+ });
13276
+ const encoder = new TextEncoder();
13277
+ let cursor = "";
13278
+ const stream = new ReadableStream({
13279
+ start(controller) {
13280
+ controller.enqueue(encoder.encode(`\uFEFF${header2.map(csvCell).join(",")}\r
13281
+ `));
13282
+ },
13283
+ async pull(controller) {
13284
+ if (cursor === null) {
13285
+ controller.close();
13286
+ return;
13287
+ }
13288
+ try {
13289
+ const page = await cms.find(
13290
+ slug2,
13291
+ {
13292
+ ...read2,
13293
+ ...where ? { where } : {},
13294
+ ...options.sort?.length ? { sort: options.sort } : {},
13295
+ limit: STEP,
13296
+ depth: 1,
13297
+ cursor
13298
+ }
13299
+ );
13300
+ const lines = page.docs.map(
13301
+ (doc) => columns.map((c) => csvCell(cellOf(cms, c.field, doc[c.key], c.key, lang))).join(",")
13302
+ );
13303
+ if (lines.length) controller.enqueue(encoder.encode(`${lines.join("\r\n")}\r
13304
+ `));
13305
+ cursor = page.nextCursor ?? null;
13306
+ if (cursor === null) controller.close();
13307
+ } catch (error) {
13308
+ controller.error(error);
13309
+ }
13310
+ }
13311
+ });
13312
+ const day = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
13313
+ return new Response(stream, {
13314
+ headers: {
13315
+ "content-type": "text/csv; charset=utf-8",
13316
+ "content-disposition": `attachment; filename="${slug2}-${day}.csv"`,
13317
+ "cache-control": "no-store",
13318
+ "x-content-type-options": "nosniff"
13319
+ }
13320
+ });
13321
+ }
13322
+
13100
13323
  // src/rest/filter-options.ts
13101
13324
  function fieldAt2(fields, path) {
13102
13325
  let current = fields;
@@ -13138,12 +13361,12 @@ async function blockSummaries(cms, user, body) {
13138
13361
  const summary = field.blocks.find((b) => b.slug === blockType)?.admin?.summary;
13139
13362
  if (typeof id !== "string" || typeof summary !== "function") continue;
13140
13363
  try {
13141
- const text3 = await summary({
13364
+ const text4 = await summary({
13142
13365
  fields: fields && typeof fields === "object" ? fields : {},
13143
13366
  user,
13144
13367
  cms
13145
13368
  });
13146
- if (typeof text3 === "string") summaries[id] = text3;
13369
+ if (typeof text4 === "string") summaries[id] = text4;
13147
13370
  } catch (error) {
13148
13371
  cms.logger.warn(`admin.summary of block "${String(blockType)}" failed: ${String(error)}`);
13149
13372
  }
@@ -13252,6 +13475,191 @@ function parseDepth(url) {
13252
13475
  return depth === void 0 ? {} : { depth };
13253
13476
  }
13254
13477
 
13478
+ // src/rest/saved-views.ts
13479
+ var MAX_OWN_VIEWS = 50;
13480
+ var MAX_STATE = 1e4;
13481
+ var QUERY_KEY = /^(where\[and\]\[\d+\]\[[^\]]+\]\[[a-z_]+\]|q|sort|scheduled|type|calendar)$/;
13482
+ var userKey = (user) => String(user.id);
13483
+ var stamps = () => {
13484
+ const now = (/* @__PURE__ */ new Date()).toISOString();
13485
+ return { createdAt: now, updatedAt: now };
13486
+ };
13487
+ async function readable2(cms, slug2, who) {
13488
+ if (INTERNAL_COLLECTIONS.has(slug2) || !cms.config.collections.some((c) => c.slug === slug2))
13489
+ throw new NotFoundError("collections", slug2);
13490
+ try {
13491
+ await cms.count(slug2, { ...who, overrideAccess: false, where: { id: { exists: false } } });
13492
+ } catch {
13493
+ throw new ForbiddenError();
13494
+ }
13495
+ }
13496
+ function cleanState(raw) {
13497
+ const input = raw && typeof raw === "object" ? raw : {};
13498
+ const query = {};
13499
+ const rawQuery = input.query && typeof input.query === "object" ? input.query : {};
13500
+ for (const [key, value] of Object.entries(rawQuery))
13501
+ if (QUERY_KEY.test(key) && typeof value === "string") query[key] = value;
13502
+ const columns = Array.isArray(input.columns) ? input.columns.filter((c) => typeof c === "string").slice(0, 50) : void 0;
13503
+ const pageSize = [10, 25, 50, 100].includes(input.pageSize) ? input.pageSize : void 0;
13504
+ const density = input.density === "compact" || input.density === "comfortable" ? input.density : void 0;
13505
+ const state = {
13506
+ query,
13507
+ ...columns ? { columns } : {},
13508
+ ...pageSize ? { pageSize } : {},
13509
+ ...density ? { density } : {}
13510
+ };
13511
+ if (JSON.stringify(state).length > MAX_STATE)
13512
+ throw new ValidationError(SAVED_VIEWS, [{ field: "state", message: "is too large" }]);
13513
+ return state;
13514
+ }
13515
+ function toView(row, user) {
13516
+ const mine2 = row.user === userKey(user);
13517
+ return {
13518
+ id: row.id,
13519
+ name: String(row.name ?? ""),
13520
+ shared: row.shared === true,
13521
+ mine: mine2,
13522
+ editable: mine2 || row.shared === true && isSystemAdmin(user),
13523
+ state: cleanState(row.state)
13524
+ };
13525
+ }
13526
+ async function rows(cms, slug2, where) {
13527
+ const found = await cms.db.find({
13528
+ collection: SAVED_VIEWS,
13529
+ where: { and: [{ collection: { equals: slug2 } }, where] },
13530
+ sort: ["id"],
13531
+ limit: 0,
13532
+ page: 1
13533
+ });
13534
+ return found.docs;
13535
+ }
13536
+ async function listViews(cms, slug2, who) {
13537
+ await readable2(cms, slug2, who);
13538
+ const key = userKey(who.user);
13539
+ const found = await rows(cms, slug2, {
13540
+ or: [
13541
+ { and: [{ kind: { equals: "view" } }, { shared: { equals: true } }] },
13542
+ { user: { equals: key } }
13543
+ ]
13544
+ });
13545
+ const views = found.filter((r) => r.kind === "view").map((r) => toView(r, who.user));
13546
+ const preference = found.find((r) => r.kind === "default" && r.user === key);
13547
+ return {
13548
+ views: [...views.filter((v) => v.shared), ...views.filter((v) => !v.shared)],
13549
+ default: typeof preference?.target === "string" ? preference.target : null
13550
+ };
13551
+ }
13552
+ function checkName(name) {
13553
+ const trimmed = typeof name === "string" ? name.trim() : "";
13554
+ if (trimmed === "" || trimmed.length > 60)
13555
+ throw new ValidationError(SAVED_VIEWS, [
13556
+ { field: "name", message: "must be 1 to 60 characters" }
13557
+ ]);
13558
+ return trimmed;
13559
+ }
13560
+ async function checkUnique(cms, slug2, who, name, shared, self) {
13561
+ const same = await rows(cms, slug2, {
13562
+ and: [
13563
+ { kind: { equals: "view" } },
13564
+ { name: { equals: name } },
13565
+ shared ? { shared: { equals: true } } : { user: { equals: userKey(who.user) } }
13566
+ ]
13567
+ });
13568
+ if (same.some((r) => r.id !== self && (shared || r.shared !== true)))
13569
+ throw new ValidationError(SAVED_VIEWS, [
13570
+ { field: "name", message: `there is already a view named "${name}"` }
13571
+ ]);
13572
+ }
13573
+ async function createView(cms, slug2, who, body) {
13574
+ await readable2(cms, slug2, who);
13575
+ const input = body ?? {};
13576
+ const name = checkName(input.name);
13577
+ const shared = input.shared === true;
13578
+ await checkUnique(cms, slug2, who, name, shared);
13579
+ const own = await rows(cms, slug2, {
13580
+ and: [{ kind: { equals: "view" } }, { user: { equals: userKey(who.user) } }]
13581
+ });
13582
+ if (own.length >= MAX_OWN_VIEWS)
13583
+ throw new ValidationError(SAVED_VIEWS, [
13584
+ { field: "name", message: `at most ${MAX_OWN_VIEWS} views per list` }
13585
+ ]);
13586
+ const row = await cms.db.create({
13587
+ collection: SAVED_VIEWS,
13588
+ data: {
13589
+ collection: slug2,
13590
+ user: userKey(who.user),
13591
+ kind: "view",
13592
+ name,
13593
+ shared,
13594
+ state: cleanState(input.state),
13595
+ target: null,
13596
+ ...stamps()
13597
+ }
13598
+ });
13599
+ return toView(row, who.user);
13600
+ }
13601
+ async function editable(cms, slug2, who, id) {
13602
+ await readable2(cms, slug2, who);
13603
+ const row = await cms.db.findById({ collection: SAVED_VIEWS, id });
13604
+ if (!row || row.collection !== slug2 || row.kind !== "view")
13605
+ throw new NotFoundError(SAVED_VIEWS, id);
13606
+ const view = toView(row, who.user);
13607
+ if (!view.mine && !view.shared) throw new NotFoundError(SAVED_VIEWS, id);
13608
+ if (!view.editable) throw new ForbiddenError("Only its owner or an admin can change this view");
13609
+ return row;
13610
+ }
13611
+ async function updateView(cms, slug2, who, id, body) {
13612
+ const row = await editable(cms, slug2, who, id);
13613
+ const input = body ?? {};
13614
+ const name = input.name === void 0 ? String(row.name) : checkName(input.name);
13615
+ const shared = input.shared === void 0 ? row.shared === true : input.shared === true;
13616
+ if (name !== row.name || shared !== (row.shared === true))
13617
+ await checkUnique(cms, slug2, who, name, shared, id);
13618
+ const updated = await cms.db.update({
13619
+ collection: SAVED_VIEWS,
13620
+ id,
13621
+ // The whole row, as the adapter writes it.
13622
+ data: {
13623
+ ...row,
13624
+ name,
13625
+ shared,
13626
+ ...input.state !== void 0 ? { state: cleanState(input.state) } : {},
13627
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
13628
+ }
13629
+ });
13630
+ return toView(updated, who.user);
13631
+ }
13632
+ async function deleteView(cms, slug2, who, id) {
13633
+ await editable(cms, slug2, who, id);
13634
+ await cms.db.delete({ collection: SAVED_VIEWS, id });
13635
+ return { deleted: 1 };
13636
+ }
13637
+ async function setDefaultView(cms, slug2, who, body) {
13638
+ await readable2(cms, slug2, who);
13639
+ const raw = body ?? {};
13640
+ const target = raw.view === null || raw.view === void 0 ? null : String(raw.view);
13641
+ const key = userKey(who.user);
13642
+ const current = await rows(cms, slug2, {
13643
+ and: [{ kind: { equals: "default" } }, { user: { equals: key } }]
13644
+ });
13645
+ for (const row of current) await cms.db.delete({ collection: SAVED_VIEWS, id: row.id });
13646
+ if (target !== null)
13647
+ await cms.db.create({
13648
+ collection: SAVED_VIEWS,
13649
+ data: {
13650
+ collection: slug2,
13651
+ user: key,
13652
+ kind: "default",
13653
+ name: null,
13654
+ shared: false,
13655
+ state: null,
13656
+ target,
13657
+ ...stamps()
13658
+ }
13659
+ });
13660
+ return { default: target };
13661
+ }
13662
+
13255
13663
  // src/rest/handler.ts
13256
13664
  var SCHEDULED_RANGE_LIMIT = 500;
13257
13665
  var CSRF_COOKIE = "ecms-csrf";
@@ -13443,7 +13851,8 @@ var ADMIN_UI_ROUTES = /* @__PURE__ */ new Set([
13443
13851
  "media-usage",
13444
13852
  "owned",
13445
13853
  "sso",
13446
- "block-summaries"
13854
+ "block-summaries",
13855
+ "views"
13447
13856
  ]);
13448
13857
  var CORS_METHODS = "GET, HEAD, POST, PATCH, PUT, DELETE";
13449
13858
  var CORS_HEADERS = `authorization, content-type, ${CSRF_HEADER}`;
@@ -13820,6 +14229,27 @@ async function route(cms, ctx, method, path, options) {
13820
14229
  if (parsed === void 0) throw new HttpError("Not found", 404);
13821
14230
  return { body: await cms.roles.owned(parsed) };
13822
14231
  }
14232
+ if (first === "admin" && second === "views" && third !== void 0 && segments.length <= 4) {
14233
+ if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
14234
+ const who = { user: ctx.user, context: ctx.context };
14235
+ const fourth = segments[3];
14236
+ if (fourth === void 0) {
14237
+ if (method === "GET") return { body: await listViews(cms, third, who) };
14238
+ if (method === "POST")
14239
+ return { status: 201, body: await createView(cms, third, who, await readJson(ctx.request)) };
14240
+ throw methodNotAllowed(ctx, "GET, POST");
14241
+ }
14242
+ if (fourth === "default") {
14243
+ if (method !== "PUT") throw methodNotAllowed(ctx, "PUT");
14244
+ return { body: await setDefaultView(cms, third, who, await readJson(ctx.request)) };
14245
+ }
14246
+ const id2 = parseId(fourth);
14247
+ if (id2 === void 0) throw new HttpError("Not found", 404);
14248
+ if (method === "PATCH")
14249
+ return { body: await updateView(cms, third, who, id2, await readJson(ctx.request)) };
14250
+ if (method === "DELETE") return { body: await deleteView(cms, third, who, id2) };
14251
+ throw methodNotAllowed(ctx, "PATCH, DELETE");
14252
+ }
13823
14253
  if (first === "admin" && second === "block-summaries" && third === void 0) {
13824
14254
  if (!ctx.user) throw new UnauthorizedError();
13825
14255
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
@@ -14166,6 +14596,21 @@ async function route(cms, ctx, method, path, options) {
14166
14596
  if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
14167
14597
  throw new HttpError(`Unknown collection "${collection2}"`, 404);
14168
14598
  }
14599
+ if (second === "export.csv" && third === void 0) {
14600
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
14601
+ if (!ctx.user) throw new UnauthorizedError();
14602
+ const { where, sort } = parseListQuery(ctx.url);
14603
+ return {
14604
+ body: await exportCsv(cms, collection2, ctx.url, {
14605
+ user: ctx.user,
14606
+ context: ctx.context,
14607
+ ...parseLocale(ctx.url),
14608
+ draft,
14609
+ ...where ? { where } : {},
14610
+ ...sort ? { sort } : {}
14611
+ })
14612
+ };
14613
+ }
14169
14614
  if (second === "bulk") return bulkRoute(cms, ctx, method, collection2, segments.slice(2), access);
14170
14615
  if (second !== void 0 && third !== void 0) {
14171
14616
  return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
@@ -14623,10 +15068,10 @@ async function readJson(request) {
14623
15068
  if (!type.toLowerCase().startsWith("application/json")) {
14624
15069
  throw new HttpError("Content-Type must be application/json", 415);
14625
15070
  }
14626
- const text3 = new TextDecoder().decode(await readBody(request, MAX_BODY_BYTES));
15071
+ const text4 = new TextDecoder().decode(await readBody(request, MAX_BODY_BYTES));
14627
15072
  let body;
14628
15073
  try {
14629
- body = JSON.parse(text3);
15074
+ body = JSON.parse(text4);
14630
15075
  } catch {
14631
15076
  throw new HttpError("Request body is not valid JSON", 400);
14632
15077
  }