@appsoftwareltd/etherpk-mcp 0.11.1 → 0.12.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/main.js CHANGED
@@ -717,7 +717,7 @@ function looksLikeAgentSetupCode(value) {
717
717
  }
718
718
  var package_default = {
719
719
  name: "@appsoftwareltd/etherpk-mcp",
720
- version: "0.11.1",
720
+ version: "0.12.0",
721
721
  license: "Elastic-2.0",
722
722
  description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
723
723
  type: "module",
@@ -3616,6 +3616,28 @@ function compareGroups$1(a, b) {
3616
3616
  return conceptKey$1(a.sourceConcept).localeCompare(conceptKey$1(b.sourceConcept));
3617
3617
  }
3618
3618
  //#endregion
3619
+ //#region ../client/src/lib/document/backlinks/shown-backlinks.ts
3620
+ /**
3621
+ * The groups with a Template's body links left out and its title kept. A Template whose only
3622
+ * links are in its body drops out whole.
3623
+ */
3624
+ function withoutTemplateContent(groups) {
3625
+ const kept = [];
3626
+ for (const group of groups) {
3627
+ if (!group.sourceIsTemplate) {
3628
+ kept.push(group);
3629
+ continue;
3630
+ }
3631
+ const refs = group.refs.filter((ref) => ref.kind === "title");
3632
+ if (refs.length > 0) kept.push({
3633
+ ...group,
3634
+ refs,
3635
+ bodyNames: []
3636
+ });
3637
+ }
3638
+ return kept;
3639
+ }
3640
+ //#endregion
3619
3641
  //#region ../client/src/lib/document/index-db-sqlite.ts
3620
3642
  /**
3621
3643
  * Adapter from the `@sqlite.org/sqlite-wasm` oo1 DB to the engine-agnostic
@@ -4564,7 +4586,8 @@ function blocksAndLinks(text) {
4564
4586
  };
4565
4587
  }
4566
4588
  /** Derive the index rows for a single document's text. Pure. */
4567
- function deriveDoc(text) {
4589
+ function deriveDoc(text, options = {}) {
4590
+ if (options.template) return deriveTemplate(text);
4568
4591
  const { blocks, links, lineToBlock } = blocksAndLinks(text);
4569
4592
  const candidateTasks = blocks.filter((b) => b.kind === "task");
4570
4593
  const fenced = candidateTasks.length === 0 ? () => false : fencedLines(text);
@@ -4595,6 +4618,21 @@ function deriveDoc(text) {
4595
4618
  searchText: withoutExtensionFences(text, blocks, lineToBlock, maps.searchText)
4596
4619
  };
4597
4620
  }
4621
+ /** A variable's opening braces in a link target: `[[{{date}}]]` names no concept until copied. */
4622
+ var VARIABLE_IN_TARGET = /\{\{/;
4623
+ function deriveTemplate(text) {
4624
+ const { blocks, links, lineToBlock } = blocksAndLinks(text);
4625
+ const maps = deriveMaps(text, blocks, links, lineToBlock);
4626
+ return {
4627
+ blocks,
4628
+ links: links.filter((link) => !VARIABLE_IN_TARGET.test(link.concept)),
4629
+ tasks: [],
4630
+ taskConcepts: [],
4631
+ mapItems: [],
4632
+ mapConcepts: [],
4633
+ searchText: withoutExtensionFences(text, blocks, lineToBlock, maps.searchText)
4634
+ };
4635
+ }
4598
4636
  /**
4599
4637
  * What search and Semantic Search read for each block holding an [[Extension Fence]] (ADR 0138):
4600
4638
  * the block's text with the fence's lines left out, so an extension's data (a drawing's elements)
@@ -5162,10 +5200,12 @@ CREATE TABLE IF NOT EXISTS index_metadata (
5162
5200
  key TEXT PRIMARY KEY, value INTEGER NOT NULL);
5163
5201
  -- protected: the stored text holds a cipher fence (fence-info.ts). Kept on the page row so a
5164
5202
  -- listing can wear a padlock without opening the document - the one cheap answer the tab has.
5203
+ -- template: the page is a [[Template]] (ADR 0144). Its links are kept for a rename and marked
5204
+ -- by this, so the views that show relations can leave them out. Never set on a protected page.
5165
5205
  CREATE TABLE IF NOT EXISTS pages (
5166
5206
  id INTEGER PRIMARY KEY, concept TEXT NOT NULL, concept_key TEXT NOT NULL, kind TEXT NOT NULL,
5167
5207
  text_hash TEXT NOT NULL DEFAULT '', generation INTEGER NOT NULL DEFAULT 1,
5168
- protected INTEGER NOT NULL DEFAULT 0);
5208
+ protected INTEGER NOT NULL DEFAULT 0, template INTEGER NOT NULL DEFAULT 0);
5169
5209
  CREATE TABLE IF NOT EXISTS aliases (
5170
5210
  page_id INTEGER NOT NULL, alias_key TEXT NOT NULL, display TEXT NOT NULL);
5171
5211
  -- A publication page's includes (ADR 0082), one row per slot: page_id is the PUBLICATION
@@ -5273,7 +5313,7 @@ function createSchema(db) {
5273
5313
  db.exec(SCHEMA$1);
5274
5314
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
5275
5315
  db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
5276
- db.exec(`PRAGMA user_version = 16`);
5316
+ db.exec(`PRAGMA user_version = 17`);
5277
5317
  }
5278
5318
  function activeIndexGeneration(db) {
5279
5319
  return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
@@ -5294,7 +5334,7 @@ function advanceIndexRevision(db) {
5294
5334
  */
5295
5335
  function isUsableIndex(db) {
5296
5336
  try {
5297
- return db.all("PRAGMA user_version")[0]?.user_version === 16;
5337
+ return db.all("PRAGMA user_version")[0]?.user_version === 17;
5298
5338
  } catch {
5299
5339
  return false;
5300
5340
  }
@@ -5347,10 +5387,11 @@ function indexDocHash(doc) {
5347
5387
  const includes = doc.includes ?? [];
5348
5388
  const properties = doc.properties ?? [];
5349
5389
  const aliases = doc.aliases ?? [];
5350
- if (includes.length === 0 && properties.length === 0 && aliases.length === 0) return hashText(doc.text);
5390
+ if (includes.length === 0 && properties.length === 0 && aliases.length === 0 && !doc.template) return hashText(doc.text);
5351
5391
  const includeText = includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0");
5352
5392
  const propertyText = properties.map((p) => `${p.key}\u0001${p.value ?? ""}`).join("\0");
5353
- return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}\u0004${aliases.join("\0")}`);
5393
+ const templateMark = doc.template ? "template" : "";
5394
+ return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}\u0004${aliases.join("\0")}${templateMark}`);
5354
5395
  }
5355
5396
  function hashText(text) {
5356
5397
  let h1 = 3735928559;
@@ -5409,7 +5450,7 @@ function insertDerived(db, pageId, doc) {
5409
5450
  t.matchEnd,
5410
5451
  null
5411
5452
  ]);
5412
- const { blocks, links, tasks, taskConcepts, mapItems, mapConcepts, searchText } = deriveDoc(doc.text);
5453
+ const { blocks, links, tasks, taskConcepts, mapItems, mapConcepts, searchText } = deriveDoc(doc.text, { template: templateFlag(doc) === 1 });
5413
5454
  for (const b of blocks) {
5414
5455
  db.run("INSERT INTO blocks (page_id, local_id, parent_local_id, ord, kind, depth, done, start_line, end_line, label, text) VALUES (?,?,?,?,?,?,?,?,?,?,?)", [
5415
5456
  pageId,
@@ -5615,15 +5656,23 @@ function purgeIfProtected(db, generation) {
5615
5656
  function protectedFlag(doc) {
5616
5657
  return containsCipherFence(doc.text) ? 1 : 0;
5617
5658
  }
5659
+ /**
5660
+ * The `template` column as stored. A [[Protected Document]] is never a [[Template]] (ADR 0144):
5661
+ * its text cannot be read for a copy, and the index holds none of its frontmatter's meaning.
5662
+ */
5663
+ function templateFlag(doc) {
5664
+ return doc.template === true && doc.kind === "page" && protectedFlag(doc) === 0 ? 1 : 0;
5665
+ }
5618
5666
  function insertPage(db, pageId, generation, doc) {
5619
- db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected) VALUES (?,?,?,?,?,?,?)", [
5667
+ db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected, template) VALUES (?,?,?,?,?,?,?,?)", [
5620
5668
  pageId,
5621
5669
  doc.concept,
5622
5670
  conceptKey$1(doc.concept),
5623
5671
  doc.kind,
5624
5672
  indexDocHash(doc),
5625
5673
  generation,
5626
- protectedFlag(doc)
5674
+ protectedFlag(doc),
5675
+ templateFlag(doc)
5627
5676
  ]);
5628
5677
  insertDerived(db, pageId, doc);
5629
5678
  }
@@ -5708,23 +5757,25 @@ function ingestOne(db, doc) {
5708
5757
  if (found.length > 0) {
5709
5758
  pageId = found[0].id;
5710
5759
  deleteDerivedRows(db, pageId);
5711
- db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ? WHERE id = ?", [
5760
+ db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ?, template = ? WHERE id = ?", [
5712
5761
  doc.concept,
5713
5762
  doc.kind,
5714
5763
  indexDocHash(doc),
5715
5764
  protectedFlag(doc),
5765
+ templateFlag(doc),
5716
5766
  pageId
5717
5767
  ]);
5718
5768
  } else {
5719
5769
  pageId = (db.all("SELECT COALESCE(MAX(id), 0) AS n FROM pages")[0]?.n ?? 0) + 1;
5720
- db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected) VALUES (?,?,?,?,?,?,?)", [
5770
+ db.run("INSERT INTO pages (id, concept, concept_key, kind, text_hash, generation, protected, template) VALUES (?,?,?,?,?,?,?,?)", [
5721
5771
  pageId,
5722
5772
  doc.concept,
5723
5773
  key,
5724
5774
  doc.kind,
5725
5775
  indexDocHash(doc),
5726
5776
  generation,
5727
- protectedFlag(doc)
5777
+ protectedFlag(doc),
5778
+ templateFlag(doc)
5728
5779
  ]);
5729
5780
  }
5730
5781
  const written = new Set(insertDerived(db, pageId, doc));
@@ -5939,10 +5990,25 @@ function conceptFamilyKeys(db, key) {
5939
5990
  function indexDocumentFactKeys(doc) {
5940
5991
  return {
5941
5992
  aliases: doc.aliases.map(conceptKey$1),
5942
- linkTargets: [.../* @__PURE__ */ new Set([...deriveTitleLinks(doc.concept).map((link) => conceptKey$1(link.concept)), ...deriveDoc(doc.text).links.map((link) => conceptKey$1(link.concept))])],
5993
+ linkTargets: [.../* @__PURE__ */ new Set([...deriveTitleLinks(doc.concept).map((link) => conceptKey$1(link.concept)), ...deriveDoc(doc.text, { template: templateFlag(doc) === 1 }).links.map((link) => conceptKey$1(link.concept))])],
5943
5994
  includeTargets: [...new Set((doc.includes ?? []).map((include) => conceptKey$1(include.concept)))].sort()
5944
5995
  };
5945
5996
  }
5997
+ /**
5998
+ * The graph's [[Template]]s (ADR 0144), ordered by name as it reads with the brackets of a
5999
+ * [[Scope]] left out, so `[[Templates]] Daily` sorts among the Ts. One query over rows the index
6000
+ * already holds: no document is opened to list them.
6001
+ */
6002
+ function templatePages(db) {
6003
+ const generation = activeIndexGeneration(db);
6004
+ const rows = db.all(`SELECT p.concept, (SELECT b.label FROM blocks b WHERE b.page_id = p.id ORDER BY b.local_id LIMIT 1) AS firstLine
6005
+ FROM pages p WHERE p.generation = ? AND p.template = 1`, [generation]);
6006
+ const sortKey = (concept) => concept.replace(/\[\[|\]\]/g, "").toLowerCase();
6007
+ return rows.map((row) => ({
6008
+ concept: row.concept,
6009
+ firstLine: row.firstLine ?? ""
6010
+ })).sort((a, b) => sortKey(a.concept).localeCompare(sortKey(b.concept)));
6011
+ }
5946
6012
  /** The canonical page id a concept resolves to (by its own name or an alias), or null. */
5947
6013
  function canonicalPageId(db, key) {
5948
6014
  const generation = activeIndexGeneration(db);
@@ -6093,7 +6159,7 @@ function backlinksFor(db, concept) {
6093
6159
  const placeholders = names.map(() => "?").join(",");
6094
6160
  const generation = activeIndexGeneration(db);
6095
6161
  const hits = db.all(`SELECT l.page_id, l.concept_key, p.concept AS sourceConcept, p.kind AS sourceKind, l.line, l.line_text,
6096
- l.match_start, l.match_end, l.block_local_id, l.in_title
6162
+ l.match_start, l.match_end, l.block_local_id, l.in_title, p.template
6097
6163
  FROM links l JOIN pages p ON p.id = l.page_id
6098
6164
  WHERE p.generation=? AND l.concept_key IN (${placeholders})
6099
6165
  ORDER BY l.page_id, l.line, l.match_start`, [generation, ...names]);
@@ -6125,7 +6191,8 @@ function backlinksFor(db, concept) {
6125
6191
  sourceKind: h.sourceKind,
6126
6192
  refs: [],
6127
6193
  bodyNames: [],
6128
- sourceIsTarget: h.page_id === pageId
6194
+ sourceIsTarget: h.page_id === pageId,
6195
+ sourceIsTemplate: h.template === 1
6129
6196
  };
6130
6197
  groups.set(h.sourceConcept, group);
6131
6198
  }
@@ -6937,7 +7004,7 @@ function cacheRoot(env) {
6937
7004
  * recovered from a joined path (see there).
6938
7005
  */
6939
7006
  var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
6940
- var INDEX_FILE_NAME = `index.v16.sqlite`;
7007
+ var INDEX_FILE_NAME = `index.v17.sqlite`;
6941
7008
  var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
6942
7009
  function cacheFile(dir) {
6943
7010
  return join(dir, CACHE_FILE_NAME);
@@ -10124,10 +10191,13 @@ function createGraphSync(deps) {
10124
10191
  let forceToken = false;
10125
10192
  let credentialRefusals = 0;
10126
10193
  let reconnectTimer;
10194
+ /** True while a retry waits on `reconnectTimer`: no socket is open or being made. */
10195
+ let retryWaiting = false;
10127
10196
  const retryDelay = deps.retryDelayMs ?? ((attempt) => reconnectDelayMs(attempt));
10128
10197
  function scheduleConnect(delayMs) {
10129
10198
  if (disposed || lost || protocolMismatch) return;
10130
10199
  clearTimeout(reconnectTimer);
10200
+ retryWaiting = true;
10131
10201
  reconnectTimer = setTimeout(connect, delayMs);
10132
10202
  }
10133
10203
  function backOff() {
@@ -10159,6 +10229,7 @@ function createGraphSync(deps) {
10159
10229
  * on open. Only rebuildable snapshot uploads use a volatile queue.
10160
10230
  */
10161
10231
  function connect() {
10232
+ retryWaiting = false;
10162
10233
  if (disposed || lost || protocolMismatch) return;
10163
10234
  const force = forceToken;
10164
10235
  forceToken = false;
@@ -10458,6 +10529,11 @@ function createGraphSync(deps) {
10458
10529
  refusalTimer = void 0;
10459
10530
  resendRefused();
10460
10531
  },
10532
+ reconnectNow() {
10533
+ if (!retryWaiting || disposed || lost || protocolMismatch) return;
10534
+ attempts = 0;
10535
+ scheduleConnect(0);
10536
+ },
10461
10537
  async flushAll({ onProgress } = {}) {
10462
10538
  const all = [...engines.values()];
10463
10539
  let flushed = 0;
@@ -11517,19 +11593,25 @@ function publicDocumentsInNoPublication(documents, publications) {
11517
11593
  //#endregion
11518
11594
  //#region ../client/src/lib/document/publish/host/site-writer.ts
11519
11595
  /**
11520
- * Stale files are removed only from the places the publisher owns. `etherpk-publish.json` is not
11521
- * produced but stays owned, so the first publish after the upgrade deletes the copy an older
11522
- * build left in the folder.
11596
+ * The folders the publisher writes into, whole: the graph's assets, the theme, and the pictures
11597
+ * it draws for maps (`maps/`) and for extension blocks (`pictures/`). Those pictures are named by
11598
+ * their content, so a changed map or an unpublished page leaves the old one behind as a stray.
11523
11599
  */
11600
+ var OWNED_FOLDERS = [
11601
+ "assets/",
11602
+ "theme/",
11603
+ "maps/",
11604
+ "pictures/"
11605
+ ];
11606
+ /** Stale files are removed only from the places the publisher owns. */
11524
11607
  function isOwnedPath(path) {
11525
- if (path.startsWith("assets/") || path.startsWith("theme/")) return true;
11608
+ if (OWNED_FOLDERS.some((folder) => path.startsWith(folder))) return true;
11526
11609
  if (path.includes("/")) return false;
11527
11610
  return /\.html$/.test(path) || [
11528
11611
  "search.json",
11529
11612
  "search-index.js",
11530
11613
  "sitemap.xml",
11531
11614
  "feed.xml",
11532
- "etherpk-publish.json",
11533
11615
  ".nojekyll"
11534
11616
  ].includes(path);
11535
11617
  }
@@ -12038,7 +12120,6 @@ not here.
12038
12120
  wrangler.jsonc
12039
12121
  AGENTS.md
12040
12122
  README.md
12041
- etherpk-publish.json
12042
12123
  `]
12043
12124
  ]);
12044
12125
  }
@@ -12058,7 +12139,7 @@ and files that are no longer produced are deleted.
12058
12139
  frontmatter says \`public: true\` and names this publication in \`publications: [${publication.id}]\`
12059
12140
  (unless the publication takes every public document).
12060
12141
  - The navigation is the outline of the publication page in EtherPK - the theme and the include
12061
- slots (head, logo, header, footer, before-content, after-content, scripts, styles) are chosen
12142
+ slots (head, favicon, logo, header, footer, before-content, after-content, scripts, styles) are chosen
12062
12143
  and edited there too, in the Publish settings and the Theme editor.
12063
12144
  - \`index.html\` opens from disk for a preview. \`.github/workflows/pages.yaml\` deploys the folder
12064
12145
  to GitHub Pages, \`wrangler.jsonc\` deploys it to Cloudflare Workers, and any static host serves it
@@ -12078,6 +12159,80 @@ function agentsMdWithManagedSection(existing, publication) {
12078
12159
  return `${existing.trimEnd()}\n\n${section}`;
12079
12160
  }
12080
12161
  //#endregion
12162
+ //#region ../client/src/lib/document/fence-answer.ts
12163
+ var PICTURE_TYPES = /* @__PURE__ */ new Set([
12164
+ "image/svg+xml",
12165
+ "image/png",
12166
+ "image/webp"
12167
+ ]);
12168
+ /** An `https` address as written, or null for any other. */
12169
+ function httpsAddress(value) {
12170
+ if (typeof value !== "string") return null;
12171
+ try {
12172
+ return new URL(value).protocol === "https:" ? value : null;
12173
+ } catch {
12174
+ return null;
12175
+ }
12176
+ }
12177
+ /** A positive, finite size, or undefined. */
12178
+ function size(value) {
12179
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
12180
+ }
12181
+ /**
12182
+ * The answer as it may be used, or null where the extension said there is nothing to show. Throws,
12183
+ * saying why, for anything else: an answer missing altogether among them, which is a mistake rather
12184
+ * than a choice.
12185
+ */
12186
+ function checkedFenceAnswer(out) {
12187
+ if (out === null) return null;
12188
+ if (typeof out !== "object") throw new Error("it answered with something that is neither a picture nor a link");
12189
+ const answer = out;
12190
+ const kind = answer.kind;
12191
+ if (kind === "picture") {
12192
+ const { type, bytes, alt } = answer;
12193
+ const width = size(answer.width);
12194
+ const height = size(answer.height);
12195
+ if (typeof type !== "string" || !PICTURE_TYPES.has(type) || !(bytes instanceof Uint8Array)) throw new Error("its picture is not an SVG, PNG or WebP file");
12196
+ if (width === void 0 || height === void 0) throw new Error("its picture has no size");
12197
+ return {
12198
+ kind: "picture",
12199
+ type,
12200
+ bytes: new Uint8Array(bytes),
12201
+ width,
12202
+ height,
12203
+ alt: String(alt ?? "")
12204
+ };
12205
+ }
12206
+ if (kind === "link") {
12207
+ const href = httpsAddress(answer.href);
12208
+ if (!href) throw new Error("its link is not an https address");
12209
+ const text = answer.text;
12210
+ const image = answer.image;
12211
+ let picture;
12212
+ if (image !== null && typeof image === "object") {
12213
+ const { src: rawSrc, alt, width: rawWidth, height: rawHeight } = image;
12214
+ const src = httpsAddress(rawSrc);
12215
+ const width = size(rawWidth);
12216
+ const height = size(rawHeight);
12217
+ if (src) picture = {
12218
+ src,
12219
+ alt: String(alt ?? ""),
12220
+ ...width !== void 0 && height !== void 0 ? {
12221
+ width,
12222
+ height
12223
+ } : {}
12224
+ };
12225
+ }
12226
+ return {
12227
+ kind: "link",
12228
+ href,
12229
+ text: String(text ?? href),
12230
+ ...picture ? { image: picture } : {}
12231
+ };
12232
+ }
12233
+ throw new Error("it answered with something that is neither a picture nor a link");
12234
+ }
12235
+ //#endregion
12081
12236
  //#region ../client/src/lib/document/publish/derived.ts
12082
12237
  function searchIndexJson(entries) {
12083
12238
  return `${JSON.stringify({
@@ -12974,6 +13129,59 @@ function linkGroups(match) {
12974
13129
  return match.groups;
12975
13130
  }
12976
13131
  //#endregion
13132
+ //#region ../client/src/lib/document/line-endings.ts
13133
+ /** `text` with every `\r\n` and every lone `\r` written as `\n`; `text` itself when it holds no `\r`. */
13134
+ function lineFeedsOnly(text) {
13135
+ return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
13136
+ }
13137
+ /**
13138
+ * The changes that turn `text` into {@link lineFeedsOnly}`(text)`, in document order and each
13139
+ * against `text` as it is: one per `\r`, deleting it before a `\n` and replacing it with `\n`
13140
+ * anywhere else. For a writer that must change a shared text in place rather than replace it,
13141
+ * applying them from the last to the first.
13142
+ */
13143
+ function lineFeedChanges(text) {
13144
+ const changes = [];
13145
+ for (let at = text.indexOf("\r"); at !== -1; at = text.indexOf("\r", at + 1)) changes.push({
13146
+ from: at,
13147
+ to: at + 1,
13148
+ insert: text[at + 1] === "\n" ? "" : "\n"
13149
+ });
13150
+ return changes;
13151
+ }
13152
+ //#endregion
13153
+ //#region ../client/src/lib/document/templates/template-page.ts
13154
+ /**
13155
+ * What makes a document a [[Template]], and the part of it a copy takes (ADR 0144).
13156
+ *
13157
+ * One predicate for every reader, so the index, the `/Template` picker, the Journal Template's
13158
+ * setting and the Headless Client cannot disagree about which pages are Templates.
13159
+ */
13160
+ /**
13161
+ * True for a [[Page]] whose frontmatter says `template: true`. The value must be the boolean, as
13162
+ * `public` must be (publication.ts), so a quoted `"true"` or a name is not a Template. A
13163
+ * [[Journal Entry]] is never one: a day is not a pattern.
13164
+ */
13165
+ function isTemplate(frontmatter, kind) {
13166
+ return kind === "page" && frontmatter.template === true;
13167
+ }
13168
+ /**
13169
+ * The text a copy of the Template takes: everything below its frontmatter, without the blank
13170
+ * lines before and after it. The frontmatter itself is never copied, so a Template that says
13171
+ * `public: true` cannot make a copy public.
13172
+ */
13173
+ function templateBody(text) {
13174
+ return withoutBlankEnds(parseFrontmatter(text).body);
13175
+ }
13176
+ /**
13177
+ * The body in the form the editor holds text: line feeds only (ADR 0112), since a file another app
13178
+ * wrote may end its lines otherwise and a copy is offset by the editor's own lines. Then without
13179
+ * the blank lines before and after it.
13180
+ */
13181
+ function withoutBlankEnds(body) {
13182
+ return lineFeedsOnly(body).replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
13183
+ }
13184
+ //#endregion
12977
13185
  //#region ../client/src/lib/document/publish/resolve.ts
12978
13186
  /**
12979
13187
  * The publication's resolver: concept → destination inside one [[Published Site]]. Alias folding
@@ -12995,12 +13203,15 @@ function titleText(concept) {
12995
13203
  return concept.replace(/\[\[|\]\]/g, "");
12996
13204
  }
12997
13205
  function createPublicationResolver(input) {
12998
- const index = buildBacklinkIndex(input.included.map((doc) => ({
12999
- concept: doc.concept,
13000
- kind: doc.kind,
13001
- aliases: [...doc.aliases],
13002
- text: parseFrontmatter(doc.text).body
13003
- })));
13206
+ const index = buildBacklinkIndex(input.included.map((doc) => {
13207
+ const { data, body } = parseFrontmatter(doc.text);
13208
+ return {
13209
+ concept: doc.concept,
13210
+ kind: doc.kind,
13211
+ aliases: [...doc.aliases],
13212
+ text: isTemplate(data, doc.kind) ? "" : body
13213
+ };
13214
+ }));
13004
13215
  const graphIndex = buildBacklinkIndex(input.allDocuments.map((doc) => ({
13005
13216
  concept: doc.concept,
13006
13217
  kind: doc.kind,
@@ -13170,12 +13381,15 @@ function buildNav(outline, resolver) {
13170
13381
  * an address keeps it whatever order the source listed it in. The generated files' names are
13171
13382
  * reserved.
13172
13383
  */
13173
- /** Names the publisher writes itself; a document wanting one is suffixed instead. */
13384
+ /**
13385
+ * The pages the publisher writes itself (the home page, the 404 page, and the journal and posts
13386
+ * archives); a document wanting one of their names is suffixed instead.
13387
+ */
13174
13388
  var RESERVED_SLUGS = [
13175
13389
  "index",
13176
13390
  "404",
13177
13391
  "journal",
13178
- "search"
13392
+ "posts"
13179
13393
  ];
13180
13394
  /** The document's `slug:` key normalised like any slug, or null when it has none. */
13181
13395
  function explicitSlugOf(text) {
@@ -13318,57 +13532,34 @@ var PICTURE_EXTENSIONS = {
13318
13532
  "image/png": "png",
13319
13533
  "image/webp": "webp"
13320
13534
  };
13321
- /** An `https` address as written, or null for any other. */
13322
- function httpsAddress(value) {
13323
- if (typeof value !== "string") return null;
13324
- try {
13325
- return new URL(value).protocol === "https:" ? value : null;
13326
- } catch {
13327
- return null;
13328
- }
13329
- }
13330
13535
  /**
13331
- * What an extension's `publish` answered for a fence, checked, its picture kept for the site under
13332
- * a name taken from its bytes: a picture, a link (`https` only, as is any picture in it), null when
13333
- * it is left out, or undefined when it could not be made, after which an ordinary fence is published
13334
- * as code and an Extension Fence left out, with a warning either way.
13536
+ * What an extension's `publish` answered for a fence, checked (`fence-answer.ts`), its picture kept
13537
+ * for the site under a name taken from its bytes: a picture, a link (`https` only, as is any picture
13538
+ * in it), null when it is left out, or undefined when it could not be made, after which an ordinary
13539
+ * fence is published as code and an Extension Fence left out, with a warning either way.
13335
13540
  */
13336
13541
  async function publishedFence(asked, info, concept, pictures, warnings) {
13337
13542
  try {
13338
- const out = await asked;
13543
+ const out = checkedFenceAnswer(await asked);
13339
13544
  if (out === null) return null;
13340
13545
  if (out.kind === "picture") {
13341
- const extension = PICTURE_EXTENSIONS[out.type];
13342
- if (!(out.bytes instanceof Uint8Array) || !extension) throw new Error("its picture is not an SVG, PNG or WebP file");
13343
13546
  const digest = await crypto.subtle.digest("SHA-256", out.bytes);
13344
- const path = `pictures/${[...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("")}.${extension}`;
13547
+ const path = `pictures/${[...new Uint8Array(digest)].slice(0, 8).map((byte) => byte.toString(16).padStart(2, "0")).join("")}.${PICTURE_EXTENSIONS[out.type]}`;
13345
13548
  pictures.set(path, out.bytes);
13346
13549
  return {
13347
13550
  kind: "picture",
13348
13551
  src: path,
13349
- alt: String(out.alt ?? ""),
13552
+ alt: out.alt,
13350
13553
  width: Math.round(out.width),
13351
13554
  height: Math.round(out.height)
13352
13555
  };
13353
13556
  }
13354
- if (out.kind === "link") {
13355
- const href = httpsAddress(out.href);
13356
- if (!href) throw new Error("its link is not an https address");
13357
- const src = httpsAddress(out.image?.src);
13358
- const image = src && out.image ? {
13359
- src,
13360
- alt: String(out.image.alt ?? ""),
13361
- width: out.image.width,
13362
- height: out.image.height
13363
- } : void 0;
13364
- return {
13365
- kind: "link",
13366
- href,
13367
- text: String(out.text ?? href),
13368
- ...image ? { image } : {}
13369
- };
13370
- }
13371
- throw new Error("it answered with something that is neither a picture nor a link");
13557
+ return {
13558
+ kind: "link",
13559
+ href: out.href,
13560
+ text: out.text,
13561
+ ...out.image ? { image: out.image } : {}
13562
+ };
13372
13563
  } catch (error) {
13373
13564
  const why = error instanceof Error ? error.message : String(error);
13374
13565
  const extensionFence = isExtensionFenceInfo(info);
@@ -14925,26 +15116,9 @@ function parseISODate(value) {
14925
15116
  function isCalendarDay(value) {
14926
15117
  return parseISODate(value) !== null;
14927
15118
  }
14928
- //#endregion
14929
- //#region ../client/src/lib/document/line-endings.ts
14930
- /** `text` with every `\r\n` and every lone `\r` written as `\n`; `text` itself when it holds no `\r`. */
14931
- function lineFeedsOnly(text) {
14932
- return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
14933
- }
14934
- /**
14935
- * The changes that turn `text` into {@link lineFeedsOnly}`(text)`, in document order and each
14936
- * against `text` as it is: one per `\r`, deleting it before a `\n` and replacing it with `\n`
14937
- * anywhere else. For a writer that must change a shared text in place rather than replace it,
14938
- * applying them from the last to the first.
14939
- */
14940
- function lineFeedChanges(text) {
14941
- const changes = [];
14942
- for (let at = text.indexOf("\r"); at !== -1; at = text.indexOf("\r", at + 1)) changes.push({
14943
- from: at,
14944
- to: at + 1,
14945
- insert: text[at + 1] === "\n" ? "" : "\n"
14946
- });
14947
- return changes;
15119
+ /** A new Date `days` after `date` (normalises across month/year boundaries). */
15120
+ function shiftDate(date, days) {
15121
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
14948
15122
  }
14949
15123
  //#endregion
14950
15124
  //#region ../client/src/lib/document/journal-concept.ts
@@ -15566,6 +15740,17 @@ function isTransient(error) {
15566
15740
  function isTransientStatus(status) {
15567
15741
  return status === 401 || status === 408 || status === 429 || status >= 500;
15568
15742
  }
15743
+ /**
15744
+ * The Sync Server gave no answer about an upload, once its retries were spent: the connection
15745
+ * failed, or it answered with a status that means "try again later". A refusal (the plan, the
15746
+ * account, the graph) and a cancellation are answers, and a file is never queued for them.
15747
+ */
15748
+ function cannotReach(error) {
15749
+ if (error instanceof AssetRefusedError) return false;
15750
+ if (error instanceof DOMException && error.name === "AbortError") return false;
15751
+ if (error instanceof HttpFailure) return error.status === 408 || error.status === 429 || error.status >= 500;
15752
+ return true;
15753
+ }
15569
15754
  function kebabStem(name) {
15570
15755
  return name.replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "asset";
15571
15756
  }
@@ -15602,6 +15787,7 @@ function assetIdFromRef(ref) {
15602
15787
  }
15603
15788
  function createServerAssetStore(deps) {
15604
15789
  const f = deps.fetch ?? fetch;
15790
+ const online = deps.online ?? (() => typeof navigator === "undefined" || navigator.onLine !== false);
15605
15791
  const base = deps.baseUrl.replace(/\/$/, "");
15606
15792
  const newAssetId = deps.newAssetId ?? (() => crypto.randomUUID());
15607
15793
  const objectUrls = [];
@@ -15714,24 +15900,20 @@ function createServerAssetStore(deps) {
15714
15900
  return fetchAssetBytesOnce(ref);
15715
15901
  }
15716
15902
  }
15717
- async function fetchAssetBytesOnce(ref) {
15718
- const assetId = assetIdFromRef(ref);
15719
- if (!assetId) return null;
15720
- const body = await requestAsset(assetId);
15721
- if (!body) return null;
15722
- const metadata = await openMetadata(assetId, body.encryptedMetadata);
15723
- if (typeof metadata.chunkCount === "number" && body.downloadUrls.length !== metadata.chunkCount) throw new AssetIntegrityError();
15903
+ /**
15904
+ * Decrypt an asset's chunks under the per-asset key its opened metadata carries, and join them.
15905
+ * The metadata is the uploader's own record of what was stored, so the chunk count, the size
15906
+ * and the hash are checked against it, and a mismatch is an `AssetIntegrityError`: the bytes
15907
+ * are never handed over in part (ADR 0027, amended 2026-10-05).
15908
+ */
15909
+ async function openChunks(ref, assetId, metadata, encrypted) {
15910
+ if (typeof metadata.chunkCount === "number" && encrypted.length !== metadata.chunkCount) throw new AssetIntegrityError();
15724
15911
  const perAssetKey = fromBase64Url(metadata.perAssetKey);
15725
15912
  const parts = [];
15726
- for (let n = 0; n < body.downloadUrls.length; n++) {
15727
- const chunkRes = await readRequest(body.downloadUrls[n]);
15728
- if (!chunkRes.ok) {
15729
- if (chunkRes.status !== 401 && isTransientStatus(chunkRes.status)) throw new AssetUnavailableError(`Storage could not hand over the file just now (HTTP ${chunkRes.status}).`, { status: chunkRes.status });
15730
- return null;
15731
- }
15913
+ for (let n = 0; n < encrypted.length; n++) {
15732
15914
  const { plaintext } = await openSymmetric({
15733
15915
  keyForEpoch: () => perAssetKey,
15734
- envelope: new Uint8Array(await chunkRes.arrayBuffer()),
15916
+ envelope: encrypted[n],
15735
15917
  aad: contextAad("asset", `id:${assetId}`, `chunk:${n}`)
15736
15918
  });
15737
15919
  parts.push(plaintext);
@@ -15751,6 +15933,166 @@ function createServerAssetStore(deps) {
15751
15933
  type: assetTypeFor(ref)
15752
15934
  };
15753
15935
  }
15936
+ /**
15937
+ * The asset from this device's copy (ADR 0143), or null when there is none. A copy that will
15938
+ * not open is passed over, and the network is asked instead: a good download replaces it.
15939
+ */
15940
+ async function fromCopy(ref, assetId) {
15941
+ if (!deps.copies) return null;
15942
+ try {
15943
+ const copy = await deps.copies.read(deps.graphId, assetId);
15944
+ if (!copy) return null;
15945
+ return await openChunks(ref, assetId, await openMetadata(assetId, copy.encryptedMetadata), copy.chunks);
15946
+ } catch (error) {
15947
+ console.warn(`[assets] the copy of ${assetId} on this device would not open; fetching it again`, error);
15948
+ return null;
15949
+ }
15950
+ }
15951
+ /** Keep `asset` on this device. A failure costs only a later download, so it is logged and passed over. */
15952
+ async function keepCopy(assetId, asset) {
15953
+ await deps.copies?.keep(deps.graphId, assetId, asset).catch((error) => {
15954
+ console.warn(`[assets] could not keep a copy of ${assetId} on this device`, error);
15955
+ });
15956
+ }
15957
+ async function fetchAssetBytesOnce(ref) {
15958
+ const assetId = assetIdFromRef(ref);
15959
+ if (!assetId) return null;
15960
+ const copied = await fromCopy(ref, assetId);
15961
+ if (copied) return copied;
15962
+ const body = await requestAsset(assetId);
15963
+ if (!body) return null;
15964
+ const metadata = await openMetadata(assetId, body.encryptedMetadata);
15965
+ if (typeof metadata.chunkCount === "number" && body.downloadUrls.length !== metadata.chunkCount) throw new AssetIntegrityError();
15966
+ const encrypted = [];
15967
+ for (let n = 0; n < body.downloadUrls.length; n++) {
15968
+ const chunkRes = await readRequest(body.downloadUrls[n]);
15969
+ if (!chunkRes.ok) {
15970
+ if (chunkRes.status !== 401 && isTransientStatus(chunkRes.status)) throw new AssetUnavailableError(`Storage could not hand over the file just now (HTTP ${chunkRes.status}).`, { status: chunkRes.status });
15971
+ return null;
15972
+ }
15973
+ encrypted.push(new Uint8Array(await chunkRes.arrayBuffer()));
15974
+ }
15975
+ const asset = await openChunks(ref, assetId, metadata, encrypted);
15976
+ await keepCopy(assetId, {
15977
+ encryptedMetadata: body.encryptedMetadata,
15978
+ chunks: encrypted
15979
+ });
15980
+ return asset;
15981
+ }
15982
+ /**
15983
+ * Begin an upload: record the metadata and get one presigned PUT URL per chunk, OR learn that a
15984
+ * complete asset in this graph already carries the dedup token. Safe to retry - the server
15985
+ * records (graph, asset) ON CONFLICT DO NOTHING, so a second attempt stores nothing new,
15986
+ * double-counts no storage, and simply re-presigns.
15987
+ */
15988
+ function begin(upload, dedupToken) {
15989
+ return step(async (fresh) => {
15990
+ const res = await f(`${base}/api/v1/sync/assets`, {
15991
+ method: "POST",
15992
+ headers: await authHeaders(fresh),
15993
+ body: JSON.stringify({
15994
+ graphId: deps.graphId,
15995
+ assetId: upload.assetId,
15996
+ size: upload.size,
15997
+ chunkCount: upload.chunkCount,
15998
+ encryptedMetadata: upload.encryptedMetadata,
15999
+ ...dedupToken ? { dedupToken } : {}
16000
+ })
16001
+ });
16002
+ if (!res.ok) throw await beginFailure(res);
16003
+ return await res.json();
16004
+ });
16005
+ }
16006
+ /** Chunk the bytes and encrypt each chunk under the per-asset key (AAD binds asset id + index). */
16007
+ async function sealChunks(bytes, assetId, perAssetKey) {
16008
+ const chunkCount = assetChunkCount(bytes.length);
16009
+ const chunks = [];
16010
+ for (let n = 0; n < chunkCount; n++) chunks.push(await sealSymmetric({
16011
+ key: perAssetKey,
16012
+ epochId: 0,
16013
+ plaintext: bytes.subarray(n * CHUNK_SIZE, (n + 1) * CHUNK_SIZE),
16014
+ aad: contextAad("asset", `id:${assetId}`, `chunk:${n}`)
16015
+ }));
16016
+ return chunks;
16017
+ }
16018
+ /**
16019
+ * Upload each encrypted chunk straight to S3 via its presigned URL. Already parallel: a single
16020
+ * large asset is not the serial part - the outer asset-to-asset loop is. Each chunk retries on
16021
+ * its own: one blip must not cost the whole asset, let alone (via the import's unwind) the whole
16022
+ * graph. `report` hears the SOURCE bytes of each chunk as it lands.
16023
+ */
16024
+ async function putChunks(uploadUrls, chunks, size, report) {
16025
+ await Promise.all(uploadUrls.map((u, n) => step(async () => {
16026
+ let r;
16027
+ try {
16028
+ r = await f(u, {
16029
+ method: "PUT",
16030
+ body: chunks[n]
16031
+ });
16032
+ } catch {
16033
+ throw new Error(!online() ? "The connection dropped while uploading to the storage bucket." : "Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's address, which whoever runs the Sync Server sets.");
16034
+ }
16035
+ if (!r.ok) throw new HttpFailure(r.status, `chunk ${n} upload failed: ${r.status}`);
16036
+ report?.(Math.min(CHUNK_SIZE, size - n * CHUNK_SIZE));
16037
+ })));
16038
+ }
16039
+ /**
16040
+ * Complete: flips the row to 'complete', which is INFORMATIONAL - nothing reads it to serve an
16041
+ * asset. Retried, but a persistent failure is swallowed: the chunks and metadata are already
16042
+ * durable, and forfeiting a twenty-minute import over a cosmetic flag is precisely the trade
16043
+ * this retry exists to prevent. A cancellation still propagates.
16044
+ */
16045
+ async function complete(assetId) {
16046
+ await step(async (fresh) => {
16047
+ const res = await f(`${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`, {
16048
+ method: "POST",
16049
+ headers: await authHeaders(fresh)
16050
+ });
16051
+ if (!res.ok) throw new HttpFailure(res.status, `asset complete failed: ${res.status}`);
16052
+ }).catch((err) => {
16053
+ if (deps.signal?.aborted) throw err;
16054
+ });
16055
+ }
16056
+ /**
16057
+ * Send the uploads queued on this device for this graph, oldest first (ADR 0143). Each goes up
16058
+ * under the id its reference already names, with no dedup token, so the server cannot answer
16059
+ * with another file. Stops at the first that fails: one the server cannot be reached for waits
16060
+ * for the next try, and one it refuses waits too, with the refusal handed back to be said.
16061
+ */
16062
+ async function sendQueued() {
16063
+ if (!deps.copies) return {
16064
+ sent: 0,
16065
+ waiting: 0
16066
+ };
16067
+ const waiting = await deps.copies.queued(deps.graphId);
16068
+ let sent = 0;
16069
+ for (const asset of waiting) {
16070
+ let beginning = true;
16071
+ try {
16072
+ const begun = await begin(asset, null);
16073
+ if ("reuse" in begun) throw new Error("The sync server answered an upload with a file it was not asked for.");
16074
+ beginning = false;
16075
+ await putChunks(begun.uploadUrls, asset.chunks, asset.size);
16076
+ await complete(asset.assetId);
16077
+ } catch (error) {
16078
+ if (!(error instanceof HttpFailure && error.status === 409)) {
16079
+ const away = !online() || beginning && cannotReach(error);
16080
+ return {
16081
+ sent,
16082
+ waiting: waiting.length - sent,
16083
+ ...away ? {} : { refusal: error }
16084
+ };
16085
+ }
16086
+ }
16087
+ await deps.copies.sent(deps.graphId, asset.assetId);
16088
+ sent += 1;
16089
+ }
16090
+ return {
16091
+ sent,
16092
+ waiting: 0
16093
+ };
16094
+ }
16095
+ let sending = null;
15754
16096
  return {
15755
16097
  async save({ name, bytes, type }, onBytes) {
15756
16098
  const ring = keyring();
@@ -15768,7 +16110,11 @@ function createServerAssetStore(deps) {
15768
16110
  isImage,
15769
16111
  ...reused ? { reused } : {}
15770
16112
  });
15771
- const dedupToken = await assetDedupToken(await dedupSecret(ring), hash);
16113
+ let reported = 0;
16114
+ const report = (n) => {
16115
+ reported += n;
16116
+ onBytes?.(n);
16117
+ };
15772
16118
  const metadata = {
15773
16119
  name,
15774
16120
  type,
@@ -15778,73 +16124,66 @@ function createServerAssetStore(deps) {
15778
16124
  size: bytes.length,
15779
16125
  chunkCount
15780
16126
  };
15781
- const encryptedMetadata = await sealSymmetric({
15782
- key: currentEpoch(ring).key,
15783
- epochId: currentEpoch(ring).epochId,
15784
- plaintext: utf8(JSON.stringify(metadata)),
15785
- aad: contextAad("asset-meta", `graph:${deps.graphId}`, `id:${assetId}`)
15786
- });
15787
- const begin = (withToken) => step(async (fresh) => {
15788
- const res = await f(`${base}/api/v1/sync/assets`, {
15789
- method: "POST",
15790
- headers: await authHeaders(fresh),
15791
- body: JSON.stringify({
15792
- graphId: deps.graphId,
15793
- assetId,
15794
- size: bytes.length,
15795
- chunkCount,
15796
- encryptedMetadata: toBase64Url(encryptedMetadata),
15797
- ...withToken ? { dedupToken } : {}
15798
- })
16127
+ const upload = {
16128
+ assetId,
16129
+ size: bytes.length,
16130
+ chunkCount,
16131
+ encryptedMetadata: toBase64Url(await sealSymmetric({
16132
+ key: currentEpoch(ring).key,
16133
+ epochId: currentEpoch(ring).epochId,
16134
+ plaintext: utf8(JSON.stringify(metadata)),
16135
+ aad: contextAad("asset-meta", `graph:${deps.graphId}`, `id:${assetId}`)
16136
+ }))
16137
+ };
16138
+ const queue = async (chunks) => {
16139
+ await deps.copies.queue(deps.graphId, {
16140
+ ...upload,
16141
+ chunks: chunks ?? await sealChunks(bytes, assetId, perAssetKey)
15799
16142
  });
15800
- if (!res.ok) throw await beginFailure(res);
15801
- return await res.json();
15802
- });
15803
- let begun = await begin(true);
16143
+ report(bytes.length - reported);
16144
+ return {
16145
+ ...saved(assetId, false),
16146
+ queued: true
16147
+ };
16148
+ };
16149
+ if (deps.copies && !online()) return queue();
16150
+ const dedupToken = await assetDedupToken(await dedupSecret(ring), hash);
16151
+ let begun;
16152
+ try {
16153
+ begun = await begin(upload, dedupToken);
16154
+ } catch (error) {
16155
+ if (deps.copies && !deps.signal?.aborted && cannotReach(error)) return queue();
16156
+ throw error;
16157
+ }
15804
16158
  if ("reuse" in begun) {
15805
16159
  if (await holdsSameBytes(begun.reuse.assetId, hash)) {
15806
- onBytes?.(bytes.length);
16160
+ report(bytes.length);
15807
16161
  return saved(begun.reuse.assetId, true);
15808
16162
  }
15809
16163
  console.warn(`[sync] the server offered asset ${begun.reuse.assetId} for reuse, but its hash differs; uploading a fresh copy`);
15810
- begun = await begin(false);
16164
+ begun = await begin(upload, null);
15811
16165
  if ("reuse" in begun) throw new Error("The sync server answered an upload with a file it was not asked for.");
15812
16166
  }
15813
- const { uploadUrls } = begun;
15814
- const chunks = [];
15815
- for (let n = 0; n < chunkCount; n++) {
15816
- const slice = bytes.subarray(n * CHUNK_SIZE, (n + 1) * CHUNK_SIZE);
15817
- chunks.push(await sealSymmetric({
15818
- key: perAssetKey,
15819
- epochId: 0,
15820
- plaintext: slice,
15821
- aad: contextAad("asset", `id:${assetId}`, `chunk:${n}`)
15822
- }));
16167
+ const chunks = await sealChunks(bytes, assetId, perAssetKey);
16168
+ try {
16169
+ await putChunks(begun.uploadUrls, chunks, bytes.length, report);
16170
+ } catch (error) {
16171
+ if (deps.copies && !deps.signal?.aborted && !online()) return queue(chunks);
16172
+ throw error;
15823
16173
  }
15824
- await Promise.all(uploadUrls.map((u, n) => step(async () => {
15825
- let r;
15826
- try {
15827
- r = await f(u, {
15828
- method: "PUT",
15829
- body: chunks[n]
15830
- });
15831
- } catch {
15832
- throw new Error(typeof navigator !== "undefined" && navigator.onLine === false ? "The connection dropped while uploading to the storage bucket." : "Uploading to the storage bucket was blocked. The bucket's CORS policy must allow this app's origin - see \"Asset storage\" in the Synced Graphs doc.");
15833
- }
15834
- if (!r.ok) throw new HttpFailure(r.status, `chunk ${n} upload failed: ${r.status}`);
15835
- onBytes?.(Math.min(CHUNK_SIZE, bytes.length - n * CHUNK_SIZE));
15836
- })));
15837
- await step(async (fresh) => {
15838
- const res = await f(`${base}/api/v1/sync/assets/${deps.graphId}/${assetId}`, {
15839
- method: "POST",
15840
- headers: await authHeaders(fresh)
15841
- });
15842
- if (!res.ok) throw new HttpFailure(res.status, `asset complete failed: ${res.status}`);
15843
- }).catch((err) => {
15844
- if (deps.signal?.aborted) throw err;
16174
+ await complete(assetId);
16175
+ await keepCopy(assetId, {
16176
+ encryptedMetadata: upload.encryptedMetadata,
16177
+ chunks
15845
16178
  });
15846
16179
  return saved(assetId, false);
15847
16180
  },
16181
+ uploadQueued() {
16182
+ sending ??= sendQueued().finally(() => {
16183
+ sending = null;
16184
+ });
16185
+ return sending;
16186
+ },
15848
16187
  readBytes: (ref) => fetchAssetBytes(ref).catch((error) => error instanceof AssetUnavailableError && error.status !== void 0 || error instanceof AssetIntegrityError ? null : Promise.reject(error)),
15849
16188
  async resolve(ref) {
15850
16189
  const asset = await fetchAssetBytes(ref);
@@ -16185,6 +16524,89 @@ async function taskLinesWith(lines, fingerprint) {
16185
16524
  return out;
16186
16525
  }
16187
16526
  //#endregion
16527
+ //#region ../client/src/lib/document/templates/template-variables.ts
16528
+ /**
16529
+ * [[Template Variable]]s: the `{{name}}` text in a [[Template]] that is filled in when the
16530
+ * Template is copied (ADR 0144).
16531
+ *
16532
+ * Every variable gives plain text, so a link is written around one (`[[{{date}}]]`). The date
16533
+ * variables read the context's `day`, which is the receiving [[Journal Entry]]'s own day or
16534
+ * today anywhere else; the time variables read the clock. A name this module does not know is
16535
+ * copied as written, so `{{name}}` in a Handlebars sample survives a copy, and a backslash before
16536
+ * the braces (`\{{date}}`) writes the braces literally.
16537
+ *
16538
+ * Pure and framework-free: the Client's insert command, the Journal Template's [[Draft]] and the
16539
+ * Headless Client all render through `renderTemplate`.
16540
+ */
16541
+ /**
16542
+ * Double braces around anything without a brace, with an optional backslash before them. One
16543
+ * pass with this pattern is the whole renderer, so a value it writes is never read again.
16544
+ */
16545
+ var VARIABLE = /(\\?)\{\{([^{}]+)\}\}/g;
16546
+ /** The format tokens, longest first so `YYYY` is never read as two `YY`s. Case-sensitive. */
16547
+ var FORMAT_TOKEN = /YYYY|YY|MM|DD|HH|mm|ss/g;
16548
+ var FORMAT_PREFIX = /^(date|time):(.+)$/i;
16549
+ /** The context for copying a Template into the document named `concept`, at `now`. */
16550
+ function templateContextFor(concept, now = /* @__PURE__ */ new Date()) {
16551
+ return {
16552
+ day: isJournalConcept(concept) ? concept.trim() : todayISO(now),
16553
+ now,
16554
+ title: concept
16555
+ };
16556
+ }
16557
+ /** Fill in `body`'s variables for `context`. */
16558
+ function renderTemplate(body, context) {
16559
+ const day = parseISODate(context.day) ?? context.now;
16560
+ let cursor = null;
16561
+ let text = "";
16562
+ let last = 0;
16563
+ for (const match of body.matchAll(VARIABLE)) {
16564
+ const [whole, backslash, inner] = match;
16565
+ text += body.slice(last, match.index);
16566
+ last = match.index + whole.length;
16567
+ if (backslash) {
16568
+ text += whole.slice(1);
16569
+ continue;
16570
+ }
16571
+ if (inner.trim().toLowerCase() === "cursor") {
16572
+ cursor ??= text.length;
16573
+ continue;
16574
+ }
16575
+ text += valueOf(inner.trim(), day, context) ?? whole;
16576
+ }
16577
+ return {
16578
+ text: text + body.slice(last),
16579
+ cursor
16580
+ };
16581
+ }
16582
+ /** The text a variable stands for, or null for a name this module does not know. */
16583
+ function valueOf(inner, day, context) {
16584
+ switch (inner.toLowerCase()) {
16585
+ case "date": return formatISODate(day);
16586
+ case "yesterday": return formatISODate(shiftDate(day, -1));
16587
+ case "tomorrow": return formatISODate(shiftDate(day, 1));
16588
+ case "time": return format("HH:mm", day, context.now);
16589
+ case "datetime": return `${formatISODate(day)} ${format("HH:mm", day, context.now)}`;
16590
+ case "title": return context.title;
16591
+ }
16592
+ const custom = FORMAT_PREFIX.exec(inner);
16593
+ return custom ? format(custom[2], day, context.now) : null;
16594
+ }
16595
+ function format(pattern, day, now) {
16596
+ const pad = (n) => String(n).padStart(2, "0");
16597
+ return pattern.replace(FORMAT_TOKEN, (token) => {
16598
+ switch (token) {
16599
+ case "YYYY": return String(day.getFullYear());
16600
+ case "YY": return pad(day.getFullYear() % 100);
16601
+ case "MM": return pad(day.getMonth() + 1);
16602
+ case "DD": return pad(day.getDate());
16603
+ case "HH": return pad(now.getHours());
16604
+ case "mm": return pad(now.getMinutes());
16605
+ default: return pad(now.getSeconds());
16606
+ }
16607
+ });
16608
+ }
16609
+ //#endregion
16188
16610
  //#region ../../extensions/graph-view/src/model/graph-model.ts
16189
16611
  /**
16190
16612
  * The [[Graph View]]'s model: the index's {@link LinkGraph} turned into a graphology graph the
@@ -16804,7 +17226,7 @@ async function backlinks(graph, concept) {
16804
17226
  await graph.store.refresh();
16805
17227
  return {
16806
17228
  concept: target,
16807
- sources: (await graph.index.backlinks(target)).map((group) => ({
17229
+ sources: withoutTemplateContent(await graph.index.backlinks(target)).map((group) => ({
16808
17230
  concept: group.sourceConcept,
16809
17231
  kind: group.sourceKind,
16810
17232
  references: group.refs.map((ref) => ({
@@ -16933,6 +17355,30 @@ async function tasks(graph, args = {}) {
16933
17355
  };
16934
17356
  }
16935
17357
  /**
17358
+ * What a tool writes from `template` and `text`: the named [[Template]]'s body with its variables
17359
+ * filled in for the document called `into`, then the text, each in the form EtherPK writes
17360
+ * (ADR 0144). `{{cursor}}` marks where a person's caret would go, so here it is only removed.
17361
+ * The Journal Template is never applied on its own: an agent's write holds what it asked for.
17362
+ */
17363
+ async function withTemplate(graph, template, into, text) {
17364
+ const parts = [];
17365
+ if (template !== void 0) parts.push(asWritten(renderTemplate(await templateBodyFor(graph, template), templateContextFor(into)).text));
17366
+ if (text !== void 0 && text !== "") parts.push(asWritten(text));
17367
+ return parts.join("\n");
17368
+ }
17369
+ /** The body a copy of the Template named `name` takes, or a refusal that says how to find one. */
17370
+ async function templateBodyFor(graph, name) {
17371
+ const identity = resolveIdentity(graph, name.trim());
17372
+ let raw = "";
17373
+ if (identity) {
17374
+ await graph.store.whenReady(identity.concept);
17375
+ raw = graph.store.openRaw(identity.concept).getText();
17376
+ }
17377
+ const { data, body } = parseFrontmatter(raw);
17378
+ if (!identity || !isTemplate(data, identity.kind) || documentProtection(body).kind === "document") throw new ToolError("invalid_argument", `"${name}" is not a Template. A page is a Template when its frontmatter says template: true; search for template:true to list them.`);
17379
+ return templateBody(raw);
17380
+ }
17381
+ /**
16936
17382
  * Text an agent sent, in the form EtherPK writes: line feeds only (ADR 0112), then the
16937
17383
  * two-space Indent Unit (ADR 0067). Line endings go first so the normaliser reads whole lines.
16938
17384
  */
@@ -16962,10 +17408,10 @@ async function editDocument(graph, args) {
16962
17408
  };
16963
17409
  }
16964
17410
  async function appendDocument(graph, args) {
16965
- const text = asWritten(args.text);
16966
- if (text.trim() === "") throw new ToolError("invalid_argument", "text must not be empty.");
16967
17411
  const target = args.concept.trim() === "today" ? todayISO() : args.concept.trim();
16968
17412
  await graph.store.refresh();
17413
+ const text = await withTemplate(graph, args.template, target, args.text);
17414
+ if (text.trim() === "") throw new ToolError("invalid_argument", "Give text, a template, or both.");
16969
17415
  const existing = resolveIdentity(graph, target);
16970
17416
  if (!existing) {
16971
17417
  if (!isJournalConcept(target)) throw new ToolError("not_found", `No document is named "${target}". Use create_page to start a new page.`);
@@ -16996,7 +17442,7 @@ async function createPage(graph, args) {
16996
17442
  if (isJournalConcept(title)) throw new ToolError("invalid_argument", `"${title}" is a calendar day, so it names a journal entry - use append_document to write to it.`);
16997
17443
  await graph.store.refresh();
16998
17444
  if (resolveIdentity(graph, title)) throw new ToolError("already_exists", `A document already answers to "${title}". Use edit_document or append_document instead.`);
16999
- await graph.store.createPage(title, asWritten(args.text ?? ""));
17445
+ await graph.store.createPage(title, await withTemplate(graph, args.template, title, args.text));
17000
17446
  if (args.frontmatter && Object.keys(args.frontmatter).length > 0) {
17001
17447
  await graph.store.whenReady(title);
17002
17448
  const raw = graph.store.openRaw(title).getText();
@@ -18709,18 +19155,20 @@ function createMcpServer(source, info) {
18709
19155
  }, async (args) => run((graph) => editDocument(graph, args)));
18710
19156
  server.registerTool("append_document", {
18711
19157
  title: "Append to a document",
18712
- description: "Add text at the end of a page or a journal entry, on a new line. A journal day that has no entry yet is created (\"today\" is accepted). A page that does not exist is not created: use create_page.",
19158
+ description: "Add text at the end of a page or a journal entry, on a new line. A journal day that has no entry yet is created (\"today\" is accepted). A page that does not exist is not created: use create_page. With template, a Template's body is added first, its variables ({{date}}, {{title}}, ...) filled in for this document; a journal day takes its own date. The graph's Journal Template is never added on its own.",
18713
19159
  inputSchema: {
18714
19160
  concept,
18715
- text: z.string().min(1)
19161
+ text: z.string().min(1).optional(),
19162
+ template: z.string().min(1).optional().describe("The name of a Template: a page whose frontmatter says template: true. Search for template:true to list them.")
18716
19163
  }
18717
19164
  }, async (args) => run((graph) => appendDocument(graph, args)));
18718
19165
  server.registerTool("create_page", {
18719
19166
  title: "Create a page",
18720
- description: "Create a new page with the given title, optional markdown body and optional frontmatter (the same keys and rules as set_frontmatter). Refuses a title that already names a page or an alias, or that is a calendar day (use append_document for journal entries).",
19167
+ description: "Create a new page with the given title, optional markdown body and optional frontmatter (the same keys and rules as set_frontmatter). With template, the page starts with that Template's body, its variables ({{date}}, {{title}}, ...) filled in for the new page, then the text. Refuses a title that already names a page or an alias, or that is a calendar day (use append_document for journal entries).",
18721
19168
  inputSchema: {
18722
19169
  title: z.string().min(1),
18723
19170
  text: z.string().optional(),
19171
+ template: z.string().min(1).optional().describe("The name of a Template: a page whose frontmatter says template: true. Search for template:true to list them."),
18724
19172
  frontmatter: z.record(z.string(), frontmatterValue).optional().describe("Frontmatter keys for the new page, e.g. {\"public\": true, \"publications\": [\"blog\"], \"date\": \"2026-01-31\"}.")
18725
19173
  }
18726
19174
  }, async (args) => run((graph) => createPage(graph, args)));
@@ -19421,14 +19869,19 @@ function sameNames(a, b) {
19421
19869
  //#region ../client/src/lib/document/index-link-graph.ts
19422
19870
  function linkGraph(db) {
19423
19871
  const generation = activeIndexGeneration(db);
19424
- const pages = db.all("SELECT concept, concept_key AS key, kind, protected FROM pages WHERE generation = ? ORDER BY id", [generation]);
19872
+ const pages = db.all("SELECT concept, concept_key AS key, kind, protected, template FROM pages WHERE generation = ? ORDER BY id", [generation]);
19425
19873
  const aliases = db.all("SELECT a.alias_key AS key, p.concept_key AS page FROM aliases a JOIN pages p ON p.id = a.page_id WHERE p.generation = ? ORDER BY a.page_id", [generation]);
19426
19874
  const rows = db.all(`SELECT p.concept_key AS source, json_group_array(json_array(l.concept_key, l.concept, l.in_title)) AS links
19427
19875
  FROM links l JOIN pages p ON p.id = l.page_id
19428
19876
  WHERE p.generation = ?
19429
19877
  GROUP BY p.id`, [generation]);
19430
19878
  const concepts = /* @__PURE__ */ new Map();
19879
+ const templates = /* @__PURE__ */ new Set();
19431
19880
  for (const page of pages) {
19881
+ if (page.template) {
19882
+ templates.add(page.key);
19883
+ continue;
19884
+ }
19432
19885
  if (concepts.has(page.key)) continue;
19433
19886
  const concept = {
19434
19887
  key: page.key,
@@ -19442,15 +19895,15 @@ function linkGraph(db) {
19442
19895
  } : concept);
19443
19896
  }
19444
19897
  const pageOfAlias = /* @__PURE__ */ new Map();
19445
- for (const alias of aliases) if (!concepts.has(alias.key) && !pageOfAlias.has(alias.key)) pageOfAlias.set(alias.key, alias.page);
19898
+ for (const alias of aliases) if (!concepts.has(alias.key) && !templates.has(alias.key) && !pageOfAlias.has(alias.key)) pageOfAlias.set(alias.key, alias.page);
19446
19899
  const resolve = (key) => concepts.has(key) ? key : pageOfAlias.get(key) ?? key;
19447
- const parsed = rows.map((row) => ({
19900
+ const parsed = rows.filter((row) => !templates.has(row.source)).map((row) => ({
19448
19901
  source: row.source,
19449
19902
  links: JSON.parse(row.links)
19450
19903
  }));
19451
19904
  const spellings = /* @__PURE__ */ new Map();
19452
19905
  for (const row of parsed) for (const [key, written] of row.links) {
19453
- if (concepts.has(resolve(key))) continue;
19906
+ if (concepts.has(resolve(key)) || templates.has(resolve(key))) continue;
19454
19907
  const counts = spellings.get(key) ?? /* @__PURE__ */ new Map();
19455
19908
  counts.set(written, (counts.get(written) ?? 0) + 1);
19456
19909
  spellings.set(key, counts);
@@ -19853,6 +20306,11 @@ function createIndexCore(host) {
19853
20306
  id: request.id,
19854
20307
  keys: db ? propertyKeys(db) : []
19855
20308
  }];
20309
+ case "templates": return [{
20310
+ type: "templates",
20311
+ id: request.id,
20312
+ templates: db ? templatePages(db) : []
20313
+ }];
19856
20314
  case "property-values": return [{
19857
20315
  type: "property-values",
19858
20316
  id: request.id,
@@ -20754,6 +21212,12 @@ function createRemoteGraphIndex(source, transport, options) {
20754
21212
  id
20755
21213
  }))).keys;
20756
21214
  },
21215
+ async templates() {
21216
+ return (await request((id) => ({
21217
+ type: "templates",
21218
+ id
21219
+ }))).templates;
21220
+ },
20757
21221
  async propertyValues(key) {
20758
21222
  return (await request((id) => ({
20759
21223
  type: "property-values",
@@ -21866,7 +22330,8 @@ function indexSnapshotOf(identity, text) {
21866
22330
  aliases: aliasesOf(fm),
21867
22331
  text: fm.body,
21868
22332
  ...includes.length > 0 ? { includes } : {},
21869
- ...properties.length > 0 ? { properties } : {}
22333
+ ...properties.length > 0 ? { properties } : {},
22334
+ ...isTemplate(fm.data, identity.kind) ? { template: true } : {}
21870
22335
  };
21871
22336
  }
21872
22337
  function applyTextChange(text, change) {
@@ -23122,14 +23587,16 @@ function createServerDocumentStore(graph, options) {
23122
23587
  text,
23123
23588
  aliases: []
23124
23589
  }) : [];
23125
- const properties = propertiesOf(parseFrontmatter(text).data);
23590
+ const frontmatter = parseFrontmatter(text).data;
23591
+ const properties = propertiesOf(frontmatter);
23126
23592
  return {
23127
23593
  concept,
23128
23594
  kind: entry.kind,
23129
23595
  aliases: entry.aliases ?? [],
23130
23596
  text: bodyForIndex(text),
23131
23597
  ...includes.length > 0 ? { includes } : {},
23132
- ...properties.length > 0 ? { properties } : {}
23598
+ ...properties.length > 0 ? { properties } : {},
23599
+ ...isTemplate(frontmatter, entry.kind) ? { template: true } : {}
23133
23600
  };
23134
23601
  }
23135
23602
  /**