@appsoftwareltd/etherpk-mcp 0.11.0 → 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 +639 -172
- package/dist/main.js.map +1 -1
- package/package.json +5 -5
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.
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
11521
|
-
*
|
|
11522
|
-
*
|
|
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 (
|
|
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
|
-
|
|
13000
|
-
|
|
13001
|
-
|
|
13002
|
-
|
|
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
|
-
/**
|
|
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
|
-
"
|
|
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
|
|
13332
|
-
* a name taken from its bytes: a picture, a link (`https` only, as is any picture
|
|
13333
|
-
* it is left out, or undefined when it could not be made, after which an ordinary
|
|
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("")}.${
|
|
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:
|
|
13552
|
+
alt: out.alt,
|
|
13350
13553
|
width: Math.round(out.width),
|
|
13351
13554
|
height: Math.round(out.height)
|
|
13352
13555
|
};
|
|
13353
13556
|
}
|
|
13354
|
-
|
|
13355
|
-
|
|
13356
|
-
|
|
13357
|
-
|
|
13358
|
-
|
|
13359
|
-
|
|
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
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
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
|
-
|
|
15718
|
-
|
|
15719
|
-
|
|
15720
|
-
|
|
15721
|
-
|
|
15722
|
-
|
|
15723
|
-
|
|
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 <
|
|
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:
|
|
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
|
-
|
|
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
|
|
15782
|
-
|
|
15783
|
-
|
|
15784
|
-
|
|
15785
|
-
|
|
15786
|
-
|
|
15787
|
-
|
|
15788
|
-
|
|
15789
|
-
|
|
15790
|
-
|
|
15791
|
-
|
|
15792
|
-
|
|
15793
|
-
|
|
15794
|
-
|
|
15795
|
-
|
|
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
|
-
|
|
15801
|
-
return
|
|
15802
|
-
|
|
15803
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
15814
|
-
|
|
15815
|
-
|
|
15816
|
-
|
|
15817
|
-
|
|
15818
|
-
|
|
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
|
|
15825
|
-
|
|
15826
|
-
|
|
15827
|
-
|
|
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,
|
|
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
|
|
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
|
/**
|