@hoardodile/host 0.1.1 → 0.1.3

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.
@@ -43,6 +43,7 @@ const HOOK_NAMES = [
43
43
  "coverLocal",
44
44
  "listFiles",
45
45
  "imageHashes",
46
+ "onInstall",
46
47
  ]
47
48
 
48
49
  const API_METHOD_NAMES = [
@@ -283,7 +284,11 @@ function send(message) {
283
284
  function approxByteSize(value) {
284
285
  if (value instanceof Uint8Array) return value.byteLength
285
286
  try {
286
- return JSON.stringify(value).length * 2
287
+ // `undefined` (hooks that legitimately return nothing — e.g.
288
+ // `onInstall`) stringifies to `undefined`, not a string: an empty
289
+ // payload must size 0, never throw into the Infinity branch.
290
+ const text = JSON.stringify(value)
291
+ return (typeof text === "string" ? text : "").length * 2
287
292
  } catch {
288
293
  return Infinity
289
294
  }
@@ -1,4 +1,7 @@
1
- import { Readable } from 'node:stream';
1
+ export { Z as ZipStreamEntry, s as streamStoredZip } from '../pack-6IscrwYN.js';
2
+ import 'node:stream';
3
+
4
+ type ContainerFormat = "zip" | "tar" | "7z" | "rar" | "xz" | "gzip";
2
5
 
3
6
  /**
4
7
  * Whole-archive extraction. Every supported format (zip, tar, 7z, rar,
@@ -33,6 +36,14 @@ type ExtractArchiveOptions = {
33
36
  /** Optional entry-count budget (enforced on the listing). */
34
37
  readonly maxEntries?: number;
35
38
  readonly onProgress?: ZipExtractReporter;
39
+ /**
40
+ * Optional format allow-list (e.g. `["zip"]` for plugin installs).
41
+ * An archive sniffed outside the list is rejected up front with
42
+ * `resource.archive_format_not_allowed` — project-internal channels
43
+ * that only ever produce one format stop admitting the others
44
+ * instead of opening every codec to untrusted input.
45
+ */
46
+ readonly formats?: readonly ContainerFormat[];
36
47
  };
37
48
  /**
38
49
  * Stream an archive into `destDir`, format-agnostic: every supported
@@ -92,28 +103,6 @@ declare function listArchiveEntries(archivePath: string): Promise<readonly Archi
92
103
  */
93
104
  declare function validateArchiveBudget(archivePath: string, maxBytes: number): Promise<void>;
94
105
 
95
- /**
96
- * On-the-fly zip exports. Resource sources are stored as bare files on
97
- * disk — nothing is packed at commit time; this module streams STORED
98
- * zip bytes straight to the consumer without a staging file.
99
- */
100
- /**
101
- * A logical zip entry whose bytes are produced on demand. Streams are
102
- * read once, in order, when the output is consumed.
103
- */
104
- type ZipStreamEntry = {
105
- readonly name: string;
106
- readonly size: number;
107
- readonly openStream: () => Readable;
108
- };
109
- /**
110
- * Stream a STORED zip from an ordered list of logical entries. Used by
111
- * the HTTP layer for exports (resource source packs, bulk downloads)
112
- * without a staging file on disk. Zero-length entries are packed as
113
- * empty buffers.
114
- */
115
- declare function streamStoredZip(entries: readonly ZipStreamEntry[]): NodeJS.ReadableStream;
116
-
117
106
  /**
118
107
  * Shared directory-size walk: the one implementation of "recursive byte
119
108
  * size of a directory tree" for host storage. Consumers opt into
@@ -199,7 +188,8 @@ declare const RESOURCE_DATA_DIR_NAME = "data";
199
188
  * per-version database snapshot), `db-backups/` (manual backups,
200
189
  * only kept for the current version), `snapshots/` (automatic daily
201
190
  * snapshots, only kept for the current version), `resources/<id>/`,
202
- * `characters/<id>/`, `plugins/<id>/` (installed content plugins
191
+ * `characters/<id>/`, `tags/<id>/` (tag art — see {@link VersionPaths.tag}),
192
+ * `plugins/<id>/` (installed content plugins
203
193
  * frozen with that version; the builtin `file` plugin is not stored
204
194
  * here). Old versions are FROZEN: no writes ever land in
205
195
  * `versions/<v>` once a `versions/<v+1>` exists.
@@ -256,10 +246,18 @@ type VersionPaths = {
256
246
  resources(): string;
257
247
  /** Root folder of all characters in this version: `<root>/versions/<v>/characters`. */
258
248
  characters(): string;
249
+ /** Root folder of all tags in this version: `<root>/versions/<v>/tags`. */
250
+ tags(): string;
259
251
  /** Root folder of all documents in this version: `<root>/versions/<v>/documents`. */
260
252
  documents(): string;
261
253
  /** Root folder of a character: `<root>/versions/<v>/characters/<id>`. */
262
254
  character(id: string): string;
255
+ /**
256
+ * Root folder of a tag: `<root>/versions/<v>/tags/<id>`. Holds the
257
+ * tag's single image slot (`image.<ext>`), the same convention as
258
+ * character avatar/fullbody slots.
259
+ */
260
+ tag(id: string): string;
263
261
  /** Root of manual backups: `<root>/versions/<v>/db-backups`. */
264
262
  dbBackups(): string;
265
263
  /** Path to one manual backup: `<root>/versions/<v>/db-backups/<name>`. */
@@ -274,7 +272,7 @@ type VersionPaths = {
274
272
  * delete cannot remove a folder whose files live under frozen past
275
273
  * archives.
276
274
  */
277
- deletedMarker(kind: "resources" | "characters", id: string): string;
275
+ deletedMarker(kind: "resources" | "characters" | "tags", id: string): string;
278
276
  /** Root folder of a document: `<root>/versions/<v>/documents/<id>`. */
279
277
  document(id: string): string;
280
278
  /**
@@ -301,11 +299,11 @@ type LocalPaths = {
301
299
  logs(): string;
302
300
  /**
303
301
  * Path to a local derived cover/thumb variant:
304
- * `<localRoot>/cache/<resources|characters>/<id>/<variant>.<format>`.
302
+ * `<localRoot>/cache/<resources|characters|tags>/<id>/<variant>.<format>`.
305
303
  * Holds synthesized covers (resource covers, character avatars and
306
- * fullbody images); re-rendered when cleared.
304
+ * fullbody images, tag art); re-rendered when cleared.
307
305
  */
308
- localCover(subjectKind: "resource" | "character", id: string, variant: string, format?: string): string;
306
+ localCover(subjectKind: "resource" | "character" | "tag", id: string, variant: string, format?: string): string;
309
307
  /**
310
308
  * Per-file derived image variant:
311
309
  * `<localRoot>/cache/resources/<id>/file-preview/<sourceCacheId>__<variantKey>.<format>`.
@@ -331,6 +329,13 @@ type LocalPaths = {
331
329
  * (b) thumbnail variants (`avatar.webp`, `fullbody.webp`).
332
330
  */
333
331
  character(id: string): string;
332
+ /**
333
+ * Root of the local per-tag directory:
334
+ * `<localRoot>/cache/tags/<id>`.
335
+ * Holds (a) versioned copies of replaced tag art and (b) the tag
336
+ * thumbnail variant (`image.avif`).
337
+ */
338
+ tag(id: string): string;
334
339
  /** Root of the trash: `<localRoot>/trash`. */
335
340
  trash(): string;
336
341
  /** Path to a single trashed item: `<localRoot>/trash/<id>`. */
@@ -351,6 +356,14 @@ type LocalPaths = {
351
356
  * `local/` (never synced) so each host has its own seal key.
352
357
  */
353
358
  sessionKey(): string;
359
+ /**
360
+ * Path to the seed-removal marker file: `<root>/local/seed-removals.json`.
361
+ * Holds the plugin ids (UUIDs) whose bundled seed was deliberately
362
+ * uninstalled by this host, so boot-time seeding skips them until the
363
+ * user restores them. Host-only state — never synced, survives app
364
+ * updates, and stays out of the wipe-on-clear `cache/` tree.
365
+ */
366
+ seedRemovals(): string;
354
367
  /**
355
368
  * Root of the host-only temporary directory tree:
356
369
  * `<localRoot>/.tmp`. Holds the global staging pool
@@ -680,6 +693,7 @@ declare function versionedDbFile(root: string, v: number): string;
680
693
  */
681
694
  declare function versionedPath(root: string, v: number): string;
682
695
 
696
+ type VersionedFolderSubjectKind = "resource" | "character" | "tag";
683
697
  type VersionedFolderOps = {
684
698
  /** Ensure the current-version entity folder exists. */
685
699
  ensureFolder(id: string): Promise<void>;
@@ -699,21 +713,9 @@ type VersionedFolderOps = {
699
713
  */
700
714
  moveFolderToTrash(id: string): Promise<string>;
701
715
  };
702
- /**
703
- * The four lifecycle operations shared by the resource and character
704
- * file-system layers. They differ only in which versioned folder they
705
- * target and how the trash / placeholder names are derived.
706
- *
707
- * `moveFolderToTrash` treats `EPERM`/`EBUSY`/`UNKNOWN` as transient Windows
708
- * locks (a file inside the folder is still open) and leaves the source in
709
- * place; a boot-time orphan sweep reclaims it later.
710
- *
711
- * `readOnly` is a live `{ current: boolean }` ref (the server's runtime
712
- * read-only flag), so a version switch mid-request re-reads it.
713
- */
714
716
  declare function buildVersionedFolderOps(paths: StoragePaths, readOnly: {
715
717
  readonly current: boolean;
716
- }, kind: "resource" | "character"): VersionedFolderOps;
718
+ }, kind: VersionedFolderSubjectKind): VersionedFolderOps;
717
719
  /**
718
720
  * Move every file in `sourceFolder` whose name matches `match` into
719
721
  * `destFolder` under a timestamped archive name (`<prefix><stamp><ext>`, or
@@ -751,4 +753,4 @@ type VersionedWriteCommand<T> = (paths: StoragePaths["latest"]) => T | Promise<T
751
753
  */
752
754
  declare function writeVersioned<T>(paths: StoragePaths, readOnly: boolean, cmd: VersionedWriteCommand<T>): Promise<T>;
753
755
 
754
- export { type CreateNextVersionResult, type CreateStoragePathsOptions, type DirSizeOptions, type LocalPaths, ORDER_MANIFEST_NAME, type OccupiedNames, PluginVaultPathError, RESOURCE_DATA_DIR_NAME, type StoragePaths, type VaultCommitResult, type VersionPaths, type VersionedFolderOps, type VersionedWriteCommand, type ZipStreamEntry, archiveStaleFiles, assertInside, assertSafeSegment, buildVersionedFolderOps, commitVaultFile, createNextVersion, createOccupiedNames, createStoragePaths, currentVersion, discardVaultTempFile, ensureBootstrapVersion, extractArchiveInto, findStagedArchiveFile, findStagedPoolFile, imageVariantKey, listArchiveEntries, listVersions, naturalSort, occupyEntryName, orderEntries, orderManifestPath, parseOrderManifest, parsePluginVaultDest, readActiveVersion, readOrderManifest, removeStagedPoolFile, resolveStagedPoolFiles, sanitizeEntryName, streamStoredZip, sumDirSizes, uniqueEntryName, validateArchiveBudget, vaultFileSha256, vaultReadFile, vaultRemoveFile, vaultStatFile, vaultTempFile, vaultTotalSize, versionedDbFile, versionedPath, writeActiveVersion, writeOrderManifest, writeStagedArchiveFile, writeStagedPoolFile, writeVersioned };
756
+ export { type CreateNextVersionResult, type CreateStoragePathsOptions, type DirSizeOptions, type LocalPaths, ORDER_MANIFEST_NAME, type OccupiedNames, PluginVaultPathError, RESOURCE_DATA_DIR_NAME, type StoragePaths, type VaultCommitResult, type VersionPaths, type VersionedFolderOps, type VersionedFolderSubjectKind, type VersionedWriteCommand, archiveStaleFiles, assertInside, assertSafeSegment, buildVersionedFolderOps, commitVaultFile, createNextVersion, createOccupiedNames, createStoragePaths, currentVersion, discardVaultTempFile, ensureBootstrapVersion, extractArchiveInto, findStagedArchiveFile, findStagedPoolFile, imageVariantKey, listArchiveEntries, listVersions, naturalSort, occupyEntryName, orderEntries, orderManifestPath, parseOrderManifest, parsePluginVaultDest, readActiveVersion, readOrderManifest, removeStagedPoolFile, resolveStagedPoolFiles, sanitizeEntryName, sumDirSizes, uniqueEntryName, validateArchiveBudget, vaultFileSha256, vaultReadFile, vaultRemoveFile, vaultStatFile, vaultTempFile, vaultTotalSize, versionedDbFile, versionedPath, writeActiveVersion, writeOrderManifest, writeStagedArchiveFile, writeStagedPoolFile, writeVersioned };
@@ -573,16 +573,16 @@ function parseSltListing(stdout, legacyNames) {
573
573
  }
574
574
  continue;
575
575
  }
576
- const sep3 = line.indexOf(" = ");
577
- if (sep3 < 0) continue;
578
- const key = line.slice(0, sep3);
576
+ const sep4 = line.indexOf(" = ");
577
+ if (sep4 < 0) continue;
578
+ const key = line.slice(0, sep4);
579
579
  if (key === "Path") {
580
- const raw = Buffer.from(line.slice(sep3 + 3), "latin1");
580
+ const raw = Buffer.from(line.slice(sep4 + 3), "latin1");
581
581
  const decoded = legacyNames ? decodeLegacyZipName(raw) : raw.toString("utf8");
582
582
  name = decoded.replace(/\\/g, "/");
583
583
  continue;
584
584
  }
585
- const value = line.slice(sep3 + 3);
585
+ const value = line.slice(sep4 + 3);
586
586
  switch (key) {
587
587
  case "Size":
588
588
  sizeBytes = Number.parseInt(value, 10) || 0;
@@ -694,6 +694,13 @@ async function extractArchiveInto(source, destDir, opts) {
694
694
  {}
695
695
  );
696
696
  }
697
+ if (opts.formats !== void 0 && !opts.formats.includes(format)) {
698
+ throw invalid(
699
+ "resource.archive_format_not_allowed",
700
+ `archive format ${format} is not allowed here (allowed: ${opts.formats.join(", ")})`,
701
+ { allowed: opts.formats }
702
+ );
703
+ }
697
704
  if (format === "zip" && resolveSevenZipPath() === void 0) {
698
705
  return extractZipBuffer(buffer2, destDir, opts);
699
706
  }
@@ -1359,65 +1366,69 @@ function createStoragePaths(opts) {
1359
1366
  }
1360
1367
  function versionAt(version) {
1361
1368
  const vSeg = assertSafeSegment(String(version));
1362
- const vRoot = join5(versionsRootPath, vSeg);
1369
+ const vRoot = join6(versionsRootPath, vSeg);
1363
1370
  return {
1364
1371
  root: vRoot,
1365
1372
  version,
1366
- versionSnapshotDb: () => join5(vRoot, "app.sqlite"),
1367
- resource: (id) => join5(vRoot, "resources", assertSafeSegment(id)),
1368
- resourceData: (id) => join5(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
1369
- resources: () => join5(vRoot, "resources"),
1370
- characters: () => join5(vRoot, "characters"),
1371
- documents: () => join5(vRoot, "documents"),
1372
- character: (id) => join5(vRoot, "characters", assertSafeSegment(id)),
1373
- dbBackups: () => join5(vRoot, "db-backups"),
1374
- dbBackup: (name) => join5(vRoot, "db-backups", assertSafeSegment(name)),
1375
- snapshots: () => join5(vRoot, "snapshots"),
1376
- snapshot: (name) => join5(vRoot, "snapshots", assertSafeSegment(name)),
1377
- deletedMarker: (kind, id) => join5(vRoot, kind, assertSafeSegment(id), ".deleted"),
1378
- document: (id) => join5(vRoot, "documents", assertSafeSegment(id)),
1379
- plugins: () => join5(vRoot, "plugins"),
1380
- pluginVaultDir: (id) => join5(vRoot, "plugins", assertSafeSegment(id), "vault")
1373
+ versionSnapshotDb: () => join6(vRoot, "app.sqlite"),
1374
+ resource: (id) => join6(vRoot, "resources", assertSafeSegment(id)),
1375
+ resourceData: (id) => join6(vRoot, "resources", assertSafeSegment(id), RESOURCE_DATA_DIR_NAME),
1376
+ resources: () => join6(vRoot, "resources"),
1377
+ characters: () => join6(vRoot, "characters"),
1378
+ tags: () => join6(vRoot, "tags"),
1379
+ documents: () => join6(vRoot, "documents"),
1380
+ character: (id) => join6(vRoot, "characters", assertSafeSegment(id)),
1381
+ tag: (id) => join6(vRoot, "tags", assertSafeSegment(id)),
1382
+ dbBackups: () => join6(vRoot, "db-backups"),
1383
+ dbBackup: (name) => join6(vRoot, "db-backups", assertSafeSegment(name)),
1384
+ snapshots: () => join6(vRoot, "snapshots"),
1385
+ snapshot: (name) => join6(vRoot, "snapshots", assertSafeSegment(name)),
1386
+ deletedMarker: (kind, id) => join6(vRoot, kind, assertSafeSegment(id), ".deleted"),
1387
+ document: (id) => join6(vRoot, "documents", assertSafeSegment(id)),
1388
+ plugins: () => join6(vRoot, "plugins"),
1389
+ pluginVaultDir: (id) => join6(vRoot, "plugins", assertSafeSegment(id), "vault")
1381
1390
  };
1382
1391
  }
1383
1392
  const active = versionAt(activeVersion);
1384
1393
  const latest = versionAt(latestVersion);
1385
- const uploadStagingRootPath = join5(localRoot, ".tmp");
1386
- const cacheRoot = join5(localRoot, "cache");
1394
+ const uploadStagingRootPath = join6(localRoot, ".tmp");
1395
+ const cacheRoot = join6(localRoot, "cache");
1387
1396
  const local = {
1388
1397
  root: localRoot,
1389
1398
  cache: () => cacheRoot,
1390
- logs: () => join5(localRoot, "logs"),
1391
- localCover: (subjectKind, id, variant, format) => join5(
1399
+ logs: () => join6(localRoot, "logs"),
1400
+ localCover: (subjectKind, id, variant, format) => join6(
1392
1401
  cacheRoot,
1393
1402
  localCoverSubjectDir(subjectKind),
1394
1403
  assertSafeSegment(id),
1395
1404
  `${assertSafeSegment(variant)}.${format ?? "avif"}`
1396
1405
  ),
1397
- resFileVariant: (id, filename, variantKey, format) => join5(
1406
+ resFileVariant: (id, filename, variantKey, format) => join6(
1398
1407
  cacheRoot,
1399
1408
  "resources",
1400
1409
  assertSafeSegment(id),
1401
1410
  "file-preview",
1402
1411
  `${assertSafeSegment(sourceCacheId(filename))}__${assertSafeSegment(variantKey)}.${format ?? "avif"}`
1403
1412
  ),
1404
- resFilePreviewDir: (id) => join5(cacheRoot, "resources", assertSafeSegment(id), "file-preview"),
1405
- resFilesCache: (id) => join5(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
1406
- resource: (id) => join5(cacheRoot, "resources", assertSafeSegment(id)),
1407
- character: (id) => join5(cacheRoot, "characters", assertSafeSegment(id)),
1408
- trash: () => join5(localRoot, "trash"),
1409
- trashItem: (id) => join5(localRoot, "trash", assertSafeSegment(id)),
1410
- tmp: () => join5(cacheRoot, "tmp"),
1411
- tmpFile: (name) => join5(cacheRoot, "tmp", assertSafeSegment(name)),
1412
- sessionKey: () => join5(localRoot, ".session-key"),
1413
+ resFilePreviewDir: (id) => join6(cacheRoot, "resources", assertSafeSegment(id), "file-preview"),
1414
+ resFilesCache: (id) => join6(cacheRoot, "resources", assertSafeSegment(id), "files-cache.json"),
1415
+ resource: (id) => join6(cacheRoot, "resources", assertSafeSegment(id)),
1416
+ character: (id) => join6(cacheRoot, "characters", assertSafeSegment(id)),
1417
+ tag: (id) => join6(cacheRoot, "tags", assertSafeSegment(id)),
1418
+ trash: () => join6(localRoot, "trash"),
1419
+ trashItem: (id) => join6(localRoot, "trash", assertSafeSegment(id)),
1420
+ tmp: () => join6(cacheRoot, "tmp"),
1421
+ tmpFile: (name) => join6(cacheRoot, "tmp", assertSafeSegment(name)),
1422
+ sessionKey: () => join6(localRoot, ".session-key"),
1423
+ seedRemovals: () => join6(localRoot, "seed-removals.json"),
1413
1424
  uploadStagingRoot: () => uploadStagingRootPath,
1414
- stagingPoolRoot: () => join5(uploadStagingRootPath, "staging"),
1415
- stagingPoolFile: (fileId, ext) => join5(
1425
+ stagingPoolRoot: () => join6(uploadStagingRootPath, "staging"),
1426
+ stagingPoolFile: (fileId, ext) => join6(
1416
1427
  uploadStagingRootPath,
1417
1428
  "staging",
1418
1429
  `${assertSafeSegment(fileId)}${ext}`
1419
1430
  ),
1420
- resVideoFrame: (id, filename, timeMs) => join5(
1431
+ resVideoFrame: (id, filename, timeMs) => join6(
1421
1432
  cacheRoot,
1422
1433
  "resources",
1423
1434
  assertSafeSegment(id),
@@ -1425,14 +1436,14 @@ function createStoragePaths(opts) {
1425
1436
  assertSafeSegment(sourceCacheId(filename)),
1426
1437
  `${timeMs}.avif`
1427
1438
  ),
1428
- resExtractedDir: (id, fileVersion) => join5(
1439
+ resExtractedDir: (id, fileVersion) => join6(
1429
1440
  cacheRoot,
1430
1441
  "resources",
1431
1442
  assertSafeSegment(id),
1432
1443
  "extracted",
1433
1444
  `v${fileVersion}`
1434
1445
  ),
1435
- resExtractedEntry: (id, fileVersion, entryName) => join5(
1446
+ resExtractedEntry: (id, fileVersion, entryName) => join6(
1436
1447
  cacheRoot,
1437
1448
  "resources",
1438
1449
  assertSafeSegment(id),
@@ -1440,7 +1451,7 @@ function createStoragePaths(opts) {
1440
1451
  `v${fileVersion}`,
1441
1452
  assertSafeSegment(sourceCacheId(entryName))
1442
1453
  ),
1443
- resExtractedArchivesDir: (id, fileVersion) => join5(
1454
+ resExtractedArchivesDir: (id, fileVersion) => join6(
1444
1455
  cacheRoot,
1445
1456
  "resources",
1446
1457
  assertSafeSegment(id),
@@ -1457,11 +1468,11 @@ function createStoragePaths(opts) {
1457
1468
  latest,
1458
1469
  local,
1459
1470
  atVersion: (v) => versionAt(v),
1460
- runtimeDb: () => join5(root, "app.sqlite")
1471
+ runtimeDb: () => join6(root, "app.sqlite")
1461
1472
  };
1462
1473
  }
1463
1474
  function localCoverSubjectDir(subjectKind) {
1464
- return subjectKind === "resource" ? "resources" : "characters";
1475
+ return subjectKind === "resource" ? "resources" : subjectKind === "character" ? "characters" : "tags";
1465
1476
  }
1466
1477
  function toCacheBasename(filename) {
1467
1478
  const dot = filename.lastIndexOf(".");
@@ -1501,7 +1512,7 @@ function assertSafeSegment(segment) {
1501
1512
  }
1502
1513
  return segment;
1503
1514
  }
1504
- function join5(...segments) {
1515
+ function join6(...segments) {
1505
1516
  return resolve(...segments);
1506
1517
  }
1507
1518
  function assertInside(ancestor, candidate) {
@@ -1850,10 +1861,25 @@ async function writeVersioned(paths, readOnly, cmd) {
1850
1861
  }
1851
1862
 
1852
1863
  // src/hoard/versioned-folder-ops.ts
1864
+ var KIND_LAYOUT = {
1865
+ resource: { trashPrefix: "resources-", deletedKind: "resources" },
1866
+ character: { trashPrefix: "characters-", deletedKind: "characters" },
1867
+ tag: { trashPrefix: "tags-", deletedKind: "tags" }
1868
+ };
1853
1869
  function buildVersionedFolderOps(paths, readOnly, kind) {
1854
- const folderOf = (id) => (current) => kind === "resource" ? current.resource(id) : current.character(id);
1855
- const trashPrefix = kind === "resource" ? "resources-" : "characters-";
1856
- const deletedKind = kind === "resource" ? "resources" : "characters";
1870
+ const folderOf = (id) => (current) => {
1871
+ switch (kind) {
1872
+ case "resource":
1873
+ return current.resource(id);
1874
+ case "character":
1875
+ return current.character(id);
1876
+ case "tag":
1877
+ return current.tag(id);
1878
+ }
1879
+ };
1880
+ const layout = KIND_LAYOUT[kind];
1881
+ const trashPrefix = layout.trashPrefix;
1882
+ const deletedKind = layout.deletedKind;
1857
1883
  async function ensureFolder(id) {
1858
1884
  await writeVersioned(
1859
1885
  paths,