@appsoftwareltd/etherpk-mcp 0.7.1 → 0.8.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/README.md +187 -143
- package/dist/main.js +354 -146
- package/dist/main.js.map +1 -1
- package/package.json +6 -5
package/dist/main.js
CHANGED
|
@@ -23,15 +23,15 @@ import * as encoding from "lib0/encoding";
|
|
|
23
23
|
import { parse, stringify } from "yaml";
|
|
24
24
|
import "fake-indexeddb/auto";
|
|
25
25
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
|
-
import "@codemirror/language";
|
|
27
|
-
import { languages } from "@codemirror/language-data";
|
|
28
26
|
import { classHighlighter, highlightTree } from "@lezer/highlight";
|
|
27
|
+
import { LanguageDescription } from "@codemirror/language";
|
|
28
|
+
import { languages } from "@codemirror/language-data";
|
|
29
29
|
import MarkdownIt from "markdown-it";
|
|
30
30
|
import katex from "katex";
|
|
31
31
|
import Mustache from "mustache";
|
|
32
32
|
var package_default = {
|
|
33
33
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
34
|
-
version: "0.
|
|
34
|
+
version: "0.8.0",
|
|
35
35
|
license: "Elastic-2.0",
|
|
36
36
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
37
37
|
type: "module",
|
|
@@ -39,10 +39,11 @@ var package_default = {
|
|
|
39
39
|
bin: { "etherpk-mcp": "./bin/etherpk-mcp.js" },
|
|
40
40
|
files: ["bin", "dist"],
|
|
41
41
|
scripts: {
|
|
42
|
-
"
|
|
42
|
+
"client-tsconfig": "pnpm --dir ../client exec svelte-kit sync",
|
|
43
|
+
"build": "pnpm client-tsconfig && vite build",
|
|
43
44
|
"check": "tsc -p tsconfig.json --noEmit",
|
|
44
|
-
"test": "vitest run",
|
|
45
|
-
"test:watch": "vitest",
|
|
45
|
+
"test": "pnpm client-tsconfig && vitest run",
|
|
46
|
+
"test:watch": "pnpm client-tsconfig && vitest",
|
|
46
47
|
"prepack": "pnpm build"
|
|
47
48
|
},
|
|
48
49
|
dependencies: {
|
|
@@ -80,7 +81,7 @@ var package_default = {
|
|
|
80
81
|
homepage: "https://docs.etherpk.com/using-ai-agents-with-your-notes",
|
|
81
82
|
repository: {
|
|
82
83
|
"type": "git",
|
|
83
|
-
"url": "https://github.com/appsoftwareltd/etherpk",
|
|
84
|
+
"url": "https://github.com/appsoftwareltd/etherpk-client",
|
|
84
85
|
"directory": "apps/mcp"
|
|
85
86
|
},
|
|
86
87
|
keywords: [
|
|
@@ -1393,50 +1394,6 @@ function wrapOo1Db(db) {
|
|
|
1393
1394
|
};
|
|
1394
1395
|
}
|
|
1395
1396
|
//#endregion
|
|
1396
|
-
//#region ../client/src/lib/storage/fs/frontmatter-span.ts
|
|
1397
|
-
/** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
|
|
1398
|
-
var DELIMITER = /^---[ \t]*\r?$/;
|
|
1399
|
-
/** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
|
|
1400
|
-
function frontmatterSpan(text) {
|
|
1401
|
-
const firstBreak = text.indexOf("\n");
|
|
1402
|
-
if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
|
|
1403
|
-
const bodyFrom = firstBreak + 1;
|
|
1404
|
-
let lineStart = bodyFrom;
|
|
1405
|
-
let lines = 1;
|
|
1406
|
-
while (lineStart <= text.length) {
|
|
1407
|
-
const nextBreak = text.indexOf("\n", lineStart);
|
|
1408
|
-
const lineEnd = nextBreak === -1 ? text.length : nextBreak;
|
|
1409
|
-
lines += 1;
|
|
1410
|
-
if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
|
|
1411
|
-
let bodyTo = Math.max(bodyFrom, lineStart - 1);
|
|
1412
|
-
if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
|
|
1413
|
-
return {
|
|
1414
|
-
end: nextBreak === -1 ? text.length : nextBreak + 1,
|
|
1415
|
-
lines,
|
|
1416
|
-
body: text.slice(bodyFrom, bodyTo),
|
|
1417
|
-
bodyFrom,
|
|
1418
|
-
bodyTo
|
|
1419
|
-
};
|
|
1420
|
-
}
|
|
1421
|
-
if (nextBreak === -1) break;
|
|
1422
|
-
lineStart = nextBreak + 1;
|
|
1423
|
-
}
|
|
1424
|
-
return null;
|
|
1425
|
-
}
|
|
1426
|
-
/**
|
|
1427
|
-
* How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
|
|
1428
|
-
* document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
|
|
1429
|
-
* (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
|
|
1430
|
-
* in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
|
|
1431
|
-
* the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
|
|
1432
|
-
* would be the only O(n) step in an otherwise line-local pass.
|
|
1433
|
-
*/
|
|
1434
|
-
function frontmatterLines(lines) {
|
|
1435
|
-
if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
|
|
1436
|
-
for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
|
|
1437
|
-
return 0;
|
|
1438
|
-
}
|
|
1439
|
-
//#endregion
|
|
1440
1397
|
//#region ../client/src/lib/document/fenced-code.ts
|
|
1441
1398
|
/** A backtick fence line, tolerant of a leading plain-bullet marker (`- `) so `- ``` ` is a fence.
|
|
1442
1399
|
* Backticks only (this slice) — tildes are left untouched, so they are not auto-completed. */
|
|
@@ -1518,6 +1475,57 @@ function fencedBlocks(lines, closerTolerance = 0) {
|
|
|
1518
1475
|
}
|
|
1519
1476
|
return blocks;
|
|
1520
1477
|
}
|
|
1478
|
+
/**
|
|
1479
|
+
* A line of a fenced block as code: the indentation up to the fence's column is structure (the
|
|
1480
|
+
* block's place in the outline) and goes; whatever lies past it is the code's own and stays.
|
|
1481
|
+
*/
|
|
1482
|
+
function codeLineText(line, fenceColumn) {
|
|
1483
|
+
return line.length - line.trimStart().length >= fenceColumn ? line.slice(fenceColumn) : line.trimStart();
|
|
1484
|
+
}
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region ../client/src/lib/storage/fs/frontmatter-span.ts
|
|
1487
|
+
/** A line that is exactly a `---` delimiter (trailing spaces and tabs allowed). */
|
|
1488
|
+
var DELIMITER = /^---[ \t]*\r?$/;
|
|
1489
|
+
/** The Frontmatter of `text`, or `null` when it has none — including an unterminated opener. */
|
|
1490
|
+
function frontmatterSpan(text) {
|
|
1491
|
+
const firstBreak = text.indexOf("\n");
|
|
1492
|
+
if (firstBreak === -1 || !DELIMITER.test(text.slice(0, firstBreak))) return null;
|
|
1493
|
+
const bodyFrom = firstBreak + 1;
|
|
1494
|
+
let lineStart = bodyFrom;
|
|
1495
|
+
let lines = 1;
|
|
1496
|
+
while (lineStart <= text.length) {
|
|
1497
|
+
const nextBreak = text.indexOf("\n", lineStart);
|
|
1498
|
+
const lineEnd = nextBreak === -1 ? text.length : nextBreak;
|
|
1499
|
+
lines += 1;
|
|
1500
|
+
if (DELIMITER.test(text.slice(lineStart, lineEnd))) {
|
|
1501
|
+
let bodyTo = Math.max(bodyFrom, lineStart - 1);
|
|
1502
|
+
if (bodyTo > bodyFrom && text[bodyTo - 1] === "\r") bodyTo -= 1;
|
|
1503
|
+
return {
|
|
1504
|
+
end: nextBreak === -1 ? text.length : nextBreak + 1,
|
|
1505
|
+
lines,
|
|
1506
|
+
body: text.slice(bodyFrom, bodyTo),
|
|
1507
|
+
bodyFrom,
|
|
1508
|
+
bodyTo
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
if (nextBreak === -1) break;
|
|
1512
|
+
lineStart = nextBreak + 1;
|
|
1513
|
+
}
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* How many lines a document's [[Frontmatter]] occupies - opener and closer included - given the
|
|
1518
|
+
* document already split into lines, or 0 when it has none. The same rule as {@link frontmatterSpan}
|
|
1519
|
+
* (a lone `---` is not a block; an unterminated one is not a block), for the line-shaped consumers
|
|
1520
|
+
* in the editor that treat the block as opaque: the outliner's scans, the bullet dots, the guides,
|
|
1521
|
+
* the clamp. Those already hold `lines`, and re-joining them per keystroke to ask the text form
|
|
1522
|
+
* would be the only O(n) step in an otherwise line-local pass.
|
|
1523
|
+
*/
|
|
1524
|
+
function frontmatterLines(lines) {
|
|
1525
|
+
if (lines.length < 2 || !DELIMITER.test(lines[0])) return 0;
|
|
1526
|
+
for (let i = 1; i < lines.length; i++) if (DELIMITER.test(lines[i])) return i + 1;
|
|
1527
|
+
return 0;
|
|
1528
|
+
}
|
|
1521
1529
|
" ".repeat(2);
|
|
1522
1530
|
/** Width of the `- ` marker, which sets a bullet's content column (`indent + MARKER_WIDTH`). */
|
|
1523
1531
|
var MARKER_WIDTH = 2;
|
|
@@ -1729,11 +1737,17 @@ function normaliseIndentUnit(text) {
|
|
|
1729
1737
|
* 3. **Continuation lines** — non-bullet lines indented to a bullet's content
|
|
1730
1738
|
* column belong to the *same* block (the soft-newline-within-a-block).
|
|
1731
1739
|
*
|
|
1740
|
+
* A complete [[Fenced Code Block]] is opaque to all three: it belongs whole to the block its
|
|
1741
|
+
* opener sits in, so a blank line, a `# comment` or a `- item` inside it is code, never a block
|
|
1742
|
+
* boundary, a heading or a bullet. "Complete" is the editor's pairing (`fencedBlocks`), the same
|
|
1743
|
+
* one the outline walk (`indent-unit.ts`) takes fences whole by.
|
|
1744
|
+
*
|
|
1732
1745
|
* A block has a kind (heading / paragraph / bullet / task). Blocks have no
|
|
1733
1746
|
* persistent identity: a block is its source range in the current parse.
|
|
1734
1747
|
*
|
|
1735
|
-
* Pure and DOM-free; the
|
|
1736
|
-
* it.
|
|
1748
|
+
* Pure and DOM-free; the derived index (`index-derive.ts`) and the publisher's navigation
|
|
1749
|
+
* (`publish/nav.ts`) consume it. The editor reads the same outline through `indent-unit.ts`.
|
|
1750
|
+
* See Dual Mode Editor.md.
|
|
1737
1751
|
*/
|
|
1738
1752
|
function indentOf(line) {
|
|
1739
1753
|
return line.length - line.trimStart().length;
|
|
@@ -1762,7 +1776,21 @@ function taskDone(line) {
|
|
|
1762
1776
|
*/
|
|
1763
1777
|
function parseBlocks(markdown) {
|
|
1764
1778
|
const lines = markdown.split("\n");
|
|
1765
|
-
const
|
|
1779
|
+
const fences = fencedBlocks(lines);
|
|
1780
|
+
const outline = outlineLines(lines, fences);
|
|
1781
|
+
const fenceAt = new Map(fences.map((f) => [f.start, f]));
|
|
1782
|
+
/**
|
|
1783
|
+
* Add line `k` to a block's `buf` and return the line after it. A line that opens a complete
|
|
1784
|
+
* fenced block brings the whole block with it, so nothing inside the fence is read as structure.
|
|
1785
|
+
* A fence nested inside it starts within the range taken, so only outermost fences reach here.
|
|
1786
|
+
*/
|
|
1787
|
+
const take = (k, buf) => {
|
|
1788
|
+
buf.push(lines[k].trimStart());
|
|
1789
|
+
const fence = fenceAt.get(k);
|
|
1790
|
+
if (!fence) return k + 1;
|
|
1791
|
+
for (let n = k + 1; n <= fence.end; n++) buf.push(codeLineText(lines[n], fence.fenceColumn));
|
|
1792
|
+
return fence.end + 1;
|
|
1793
|
+
};
|
|
1766
1794
|
const flat = [];
|
|
1767
1795
|
let i = 0;
|
|
1768
1796
|
while (i < lines.length) {
|
|
@@ -1788,12 +1816,9 @@ function parseBlocks(markdown) {
|
|
|
1788
1816
|
const contentCol = indentOf(lines[i]) + 2;
|
|
1789
1817
|
const done = taskDone(lines[i]);
|
|
1790
1818
|
const start = i;
|
|
1791
|
-
const buf = [
|
|
1792
|
-
i
|
|
1793
|
-
while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol)
|
|
1794
|
-
buf.push(lines[i].trimStart());
|
|
1795
|
-
i++;
|
|
1796
|
-
}
|
|
1819
|
+
const buf = [];
|
|
1820
|
+
i = take(i, buf);
|
|
1821
|
+
while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) >= contentCol) i = take(i, buf);
|
|
1797
1822
|
flat.push({
|
|
1798
1823
|
type: done === void 0 ? "bullet" : "task",
|
|
1799
1824
|
depth,
|
|
@@ -1805,12 +1830,9 @@ function parseBlocks(markdown) {
|
|
|
1805
1830
|
continue;
|
|
1806
1831
|
}
|
|
1807
1832
|
const start = i;
|
|
1808
|
-
const buf = [
|
|
1809
|
-
i
|
|
1810
|
-
while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) === indentOf(lines[start]))
|
|
1811
|
-
buf.push(lines[i].trimStart());
|
|
1812
|
-
i++;
|
|
1813
|
-
}
|
|
1833
|
+
const buf = [];
|
|
1834
|
+
i = take(i, buf);
|
|
1835
|
+
while (i < lines.length && lines[i].trim() !== "" && headingLevel(lines[i]) === void 0 && !isBullet(lines[i]) && indentOf(lines[i]) === indentOf(lines[start])) i = take(i, buf);
|
|
1814
1836
|
flat.push({
|
|
1815
1837
|
type: "paragraph",
|
|
1816
1838
|
depth,
|
|
@@ -1997,14 +2019,12 @@ function serialiseTags(tags) {
|
|
|
1997
2019
|
/**
|
|
1998
2020
|
* A predicate over source line numbers: is this line inside a [[Fenced Code Block]]?
|
|
1999
2021
|
*
|
|
2000
|
-
*
|
|
2001
|
-
*
|
|
2002
|
-
*
|
|
2003
|
-
*
|
|
2004
|
-
*
|
|
2005
|
-
*
|
|
2006
|
-
* contribute a task, whatever it holds. That last one is a rule, not an optimisation: the
|
|
2007
|
-
* [[Derived Index]] is plaintext at rest and outlives the session.
|
|
2022
|
+
* A `- [ ] x` written inside a fence must not become a [[Task]]: the [[Tasks View]] would list
|
|
2023
|
+
* phantom tasks lifted out of code examples. The [[Block]] model now takes a complete fence
|
|
2024
|
+
* whole (block-model.ts), so no task starts inside one; this scan stays for what the block model
|
|
2025
|
+
* cannot know, that a [[Protected Document]] - an `etherpk-cipher` fence - never contributes a
|
|
2026
|
+
* task, whatever it holds. That is a rule, not an optimisation: the [[Derived Index]] is
|
|
2027
|
+
* plaintext at rest and outlives the session.
|
|
2008
2028
|
*
|
|
2009
2029
|
* "Inside a fence" is the EDITOR's answer, not a second one: the column-scoped pairing of
|
|
2010
2030
|
* `fencedBlocks` (Editor Content Rules → "Inside a block"), so a task is indexed exactly when
|
|
@@ -2040,6 +2060,15 @@ function blockLabel(block) {
|
|
|
2040
2060
|
return bulletLabel(first);
|
|
2041
2061
|
}
|
|
2042
2062
|
/**
|
|
2063
|
+
* A block as a reader sees it: the marker stripped as in its {@link blockLabel}, and every line
|
|
2064
|
+
* after the first kept - continuation lines and fenced code, which a label drops. What a
|
|
2065
|
+
* references [[View]] quotes and a semantic passage embeds.
|
|
2066
|
+
*/
|
|
2067
|
+
function blockContent(block) {
|
|
2068
|
+
const newline = block.text.indexOf("\n");
|
|
2069
|
+
return newline === -1 ? block.label : block.label + block.text.slice(newline);
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2043
2072
|
* The label of a bullet or [[Task]] line — its marker and checkbox stripped, indentation
|
|
2044
2073
|
* ignored. Exported because the [[Tasks View]]'s write-back guard has to ask "is the line in
|
|
2045
2074
|
* the document still the task the index recorded?", and the only honest way to answer is with
|
|
@@ -2225,9 +2254,7 @@ var OVERLAP_MAX_CHARS = Math.floor(PASSAGE_BUDGET_CHARS / 4);
|
|
|
2225
2254
|
var CRUMB = " > ";
|
|
2226
2255
|
/** A block's text with its bullet / task / heading marker gone, continuation lines kept. */
|
|
2227
2256
|
function blockBody(block) {
|
|
2228
|
-
|
|
2229
|
-
const rest = newline === -1 ? "" : block.text.slice(newline);
|
|
2230
|
-
return stripWikilinkBrackets(block.label + rest);
|
|
2257
|
+
return stripWikilinkBrackets(blockContent(block));
|
|
2231
2258
|
}
|
|
2232
2259
|
/** `[[Physics]]` embeds as `Physics`: the brackets are syntax, not meaning. */
|
|
2233
2260
|
function stripWikilinkBrackets(text) {
|
|
@@ -2545,7 +2572,7 @@ function createSchema(db) {
|
|
|
2545
2572
|
db.exec(SCHEMA$1);
|
|
2546
2573
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
|
|
2547
2574
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
|
|
2548
|
-
db.exec(`PRAGMA user_version =
|
|
2575
|
+
db.exec(`PRAGMA user_version = 12`);
|
|
2549
2576
|
}
|
|
2550
2577
|
function activeIndexGeneration(db) {
|
|
2551
2578
|
return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
|
|
@@ -2566,7 +2593,7 @@ function advanceIndexRevision(db) {
|
|
|
2566
2593
|
*/
|
|
2567
2594
|
function isUsableIndex(db) {
|
|
2568
2595
|
try {
|
|
2569
|
-
return db.all("PRAGMA user_version")[0]?.user_version ===
|
|
2596
|
+
return db.all("PRAGMA user_version")[0]?.user_version === 12;
|
|
2570
2597
|
} catch {
|
|
2571
2598
|
return false;
|
|
2572
2599
|
}
|
|
@@ -3077,16 +3104,40 @@ function collectSubtree(blocks, rootId) {
|
|
|
3077
3104
|
const b = blocks[id];
|
|
3078
3105
|
if (!b) return;
|
|
3079
3106
|
out.push({
|
|
3080
|
-
|
|
3107
|
+
text: blockContent(b),
|
|
3081
3108
|
depth,
|
|
3082
|
-
isMatch: id === rootId
|
|
3109
|
+
isMatch: id === rootId,
|
|
3110
|
+
...b.kind === "task" ? { done: b.done === true } : {}
|
|
3083
3111
|
});
|
|
3084
3112
|
for (const child of childrenByParent.get(id) ?? []) walk(child.localId, depth + 1);
|
|
3085
3113
|
};
|
|
3086
3114
|
walk(rootId, 0);
|
|
3087
3115
|
return out;
|
|
3088
3116
|
}
|
|
3089
|
-
/**
|
|
3117
|
+
/**
|
|
3118
|
+
* Widen `[start, end)` so that neither end falls inside a complete fenced code block. Half a code
|
|
3119
|
+
* block is no excerpt at all, and an opener cut from its closer would render as raw backticks.
|
|
3120
|
+
*/
|
|
3121
|
+
function widenToFences(text, start, end) {
|
|
3122
|
+
const lines = text.split("\n");
|
|
3123
|
+
const lineStarts = [];
|
|
3124
|
+
let offset = 0;
|
|
3125
|
+
for (const line of lines) {
|
|
3126
|
+
lineStarts.push(offset);
|
|
3127
|
+
offset += line.length + 1;
|
|
3128
|
+
}
|
|
3129
|
+
for (const fence of fencedBlocks(lines)) {
|
|
3130
|
+
const from = lineStarts[fence.start];
|
|
3131
|
+
const to = lineStarts[fence.end] + lines[fence.end].length;
|
|
3132
|
+
if (start > from && start < to) start = from;
|
|
3133
|
+
if (end > from && end < to) end = to;
|
|
3134
|
+
}
|
|
3135
|
+
return [start, end];
|
|
3136
|
+
}
|
|
3137
|
+
/**
|
|
3138
|
+
* Truncate `text` to MAX_CONTEXT chars centred on the match, with ellipses. The window grows past
|
|
3139
|
+
* MAX_CONTEXT rather than cut a fenced code block in two ({@link widenToFences}).
|
|
3140
|
+
*/
|
|
3090
3141
|
function truncateContext(text, matchStart, matchEnd) {
|
|
3091
3142
|
if (text.length <= MAX_CONTEXT) return {
|
|
3092
3143
|
text,
|
|
@@ -3095,17 +3146,17 @@ function truncateContext(text, matchStart, matchEnd) {
|
|
|
3095
3146
|
truncated: false
|
|
3096
3147
|
};
|
|
3097
3148
|
const mid = Math.floor((matchStart + matchEnd) / 2);
|
|
3098
|
-
|
|
3099
|
-
const
|
|
3100
|
-
start = Math.max(0,
|
|
3101
|
-
const prefix = start
|
|
3102
|
-
const suffix = end
|
|
3149
|
+
const windowStart = Math.max(0, mid - Math.floor(MAX_CONTEXT / 2));
|
|
3150
|
+
const windowEnd = Math.min(text.length, windowStart + MAX_CONTEXT);
|
|
3151
|
+
const [start, end] = widenToFences(text, Math.max(0, windowEnd - MAX_CONTEXT), windowEnd);
|
|
3152
|
+
const prefix = start === 0 ? "" : text[start - 1] === "\n" ? "…\n" : "…";
|
|
3153
|
+
const suffix = end === text.length ? "" : text[end] === "\n" ? "\n…" : "…";
|
|
3103
3154
|
const shift = prefix.length - start;
|
|
3104
3155
|
return {
|
|
3105
3156
|
text: prefix + text.slice(start, end) + suffix,
|
|
3106
3157
|
matchStart: Math.max(0, matchStart + shift),
|
|
3107
3158
|
matchEnd: Math.max(0, matchEnd + shift),
|
|
3108
|
-
truncated:
|
|
3159
|
+
truncated: prefix !== "" || suffix !== ""
|
|
3109
3160
|
};
|
|
3110
3161
|
}
|
|
3111
3162
|
/** Build a reference: a block subtree for bullets/tasks, prose context otherwise. */
|
|
@@ -3167,7 +3218,7 @@ function backlinksFor(db, concept) {
|
|
|
3167
3218
|
WHERE p.generation=? AND l.concept_key IN (${placeholders})`, [generation, ...names]);
|
|
3168
3219
|
const blocksByPage = /* @__PURE__ */ new Map();
|
|
3169
3220
|
for (const pid of new Set(hits.map((h) => h.page_id))) {
|
|
3170
|
-
const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
|
|
3221
|
+
const rows = db.all("SELECT local_id, parent_local_id, ord, kind, depth, done, label, text FROM blocks WHERE page_id=? ORDER BY local_id", [pid]);
|
|
3171
3222
|
const blocks = [];
|
|
3172
3223
|
for (const r of rows) blocks[r.local_id] = {
|
|
3173
3224
|
localId: r.local_id,
|
|
@@ -3175,6 +3226,7 @@ function backlinksFor(db, concept) {
|
|
|
3175
3226
|
ord: r.ord,
|
|
3176
3227
|
kind: r.kind,
|
|
3177
3228
|
depth: r.depth,
|
|
3229
|
+
...r.done === null ? {} : { done: r.done === 1 },
|
|
3178
3230
|
startLine: 0,
|
|
3179
3231
|
endLine: 0,
|
|
3180
3232
|
text: r.text,
|
|
@@ -3863,7 +3915,7 @@ function cacheRoot(env) {
|
|
|
3863
3915
|
* recovered from a joined path (see there).
|
|
3864
3916
|
*/
|
|
3865
3917
|
var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
|
|
3866
|
-
var INDEX_FILE_NAME = `index.
|
|
3918
|
+
var INDEX_FILE_NAME = `index.v12.sqlite`;
|
|
3867
3919
|
var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
|
|
3868
3920
|
function cacheFile(dir) {
|
|
3869
3921
|
return join(dir, CACHE_FILE_NAME);
|
|
@@ -4649,13 +4701,8 @@ var performanceRecorder = createPerformanceRecorder({ enabled: typeof window !==
|
|
|
4649
4701
|
var ENTITLEMENT_AUDIENCE = "urn:etherpk:sync-entitlements";
|
|
4650
4702
|
var ENTITLEMENT_SERVICE = "managed-sync";
|
|
4651
4703
|
/**
|
|
4652
|
-
* Version-agnostic, like `isUuid` on the Server
|
|
4653
|
-
*
|
|
4654
|
-
* one accepted v1 to v5, another v1 to v8, another any 36 characters of hex and dashes
|
|
4655
|
-
* (quality audit B4). If an id ever became a uuidv7 - which is what better-auth already mints
|
|
4656
|
-
* - the strictest of them would have silently stopped matching, and the Stripe webhook's
|
|
4657
|
-
* metadata fallback would have answered 409 "Unknown Stripe customer" for a real subscription.
|
|
4658
|
-
* One pattern, here, next to the claim it belongs to.
|
|
4704
|
+
* Version-agnostic, like `isUuid` on the Server: any UUID version matches, so an id minted as a
|
|
4705
|
+
* uuidv7 is recognised as readily as a v4. One pattern, here, next to the claim it belongs to.
|
|
4659
4706
|
*/
|
|
4660
4707
|
var ENTITLEMENT_SUBJECT_PATTERN = /^billing-account:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4661
4708
|
var httpsUrl = z.url().refine((value) => {
|
|
@@ -5093,7 +5140,7 @@ function createSyncProtocol(overrides = {}) {
|
|
|
5093
5140
|
}
|
|
5094
5141
|
var defaultProtocol = createSyncProtocol();
|
|
5095
5142
|
defaultProtocol.parseClientMessage;
|
|
5096
|
-
var parseServerMessage
|
|
5143
|
+
var parseServerMessage = defaultProtocol.parseServerMessage;
|
|
5097
5144
|
var serializeClientMessage$1 = defaultProtocol.serializeClientMessage;
|
|
5098
5145
|
defaultProtocol.serializeServerMessage;
|
|
5099
5146
|
//#endregion
|
|
@@ -5102,13 +5149,10 @@ defaultProtocol.serializeServerMessage;
|
|
|
5102
5149
|
* The asset chunking contract, shared by the Client that uploads and the Sync Server that
|
|
5103
5150
|
* signs the upload URLs.
|
|
5104
5151
|
*
|
|
5105
|
-
*
|
|
5106
|
-
*
|
|
5107
|
-
*
|
|
5108
|
-
*
|
|
5109
|
-
* S2). With the contract stated here the server can derive exactly how many chunks an asset
|
|
5110
|
-
* of a given size has, and exactly how long each chunk's ciphertext must be, and sign that
|
|
5111
|
-
* length into the URL.
|
|
5152
|
+
* Shared so that the declared `size` on `POST /api/v1/sync/assets` is not taken on trust: from
|
|
5153
|
+
* the contract stated here the server derives exactly how many chunks an asset of a given size
|
|
5154
|
+
* has, and exactly how long each chunk's ciphertext must be, and signs that length into each
|
|
5155
|
+
* presigned PUT URL.
|
|
5112
5156
|
*
|
|
5113
5157
|
* Changing either constant is a protocol change: the Client's chunking and the Server's
|
|
5114
5158
|
* signed lengths must move together, or every upload fails with an opaque 403.
|
|
@@ -5852,13 +5896,41 @@ function createDocSync(deps) {
|
|
|
5852
5896
|
* added and checked only at the serialisation boundary so the document engine does not repeat
|
|
5853
5897
|
* a transport constant on every operation.
|
|
5854
5898
|
*/
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5899
|
+
function readServerMessage(raw) {
|
|
5900
|
+
const result = parseServerMessage(raw);
|
|
5901
|
+
if (result.ok) {
|
|
5902
|
+
const { v: _version, ...message } = result.value;
|
|
5903
|
+
return {
|
|
5904
|
+
kind: "message",
|
|
5905
|
+
message
|
|
5906
|
+
};
|
|
5907
|
+
}
|
|
5908
|
+
if (result.code === "unsupported_version") {
|
|
5909
|
+
const { v } = JSON.parse(raw);
|
|
5910
|
+
if (typeof v === "number" && Number.isInteger(v)) return {
|
|
5911
|
+
kind: "protocol_mismatch",
|
|
5912
|
+
serverVersion: v
|
|
5913
|
+
};
|
|
5914
|
+
}
|
|
5915
|
+
return { kind: "malformed" };
|
|
5861
5916
|
}
|
|
5917
|
+
/**
|
|
5918
|
+
* The Sync Server speaks another sync protocol version, so nothing it sends can be read and
|
|
5919
|
+
* nothing this Client sends will be accepted. Retrying cannot fix it; one side has to be
|
|
5920
|
+
* upgraded. The message is for logs and the Headless Client; the browser maps the error to
|
|
5921
|
+
* its own copy in `sync-error-copy.ts`.
|
|
5922
|
+
*/
|
|
5923
|
+
var SyncProtocolMismatchError = class extends Error {
|
|
5924
|
+
name = "SyncProtocolMismatchError";
|
|
5925
|
+
constructor(serverVersion, clientVersion = 2) {
|
|
5926
|
+
super(serverVersion < clientVersion ? `The Sync Server speaks sync protocol ${serverVersion} and this Client speaks ${clientVersion}: the server is older, and its operator needs to upgrade it.` : `The Sync Server speaks sync protocol ${serverVersion} and this Client speaks ${clientVersion}: this Client is older and needs upgrading.`);
|
|
5927
|
+
this.serverVersion = serverVersion;
|
|
5928
|
+
this.clientVersion = clientVersion;
|
|
5929
|
+
}
|
|
5930
|
+
get serverIsOlder() {
|
|
5931
|
+
return this.serverVersion < this.clientVersion;
|
|
5932
|
+
}
|
|
5933
|
+
};
|
|
5862
5934
|
function serializeClientMessage(message) {
|
|
5863
5935
|
return serializeClientMessage$1({
|
|
5864
5936
|
v: 2,
|
|
@@ -6019,6 +6091,39 @@ function sanitizeQuickNotes(raw) {
|
|
|
6019
6091
|
}
|
|
6020
6092
|
return out;
|
|
6021
6093
|
}
|
|
6094
|
+
new Intl.Segmenter(void 0, { granularity: "word" });
|
|
6095
|
+
/**
|
|
6096
|
+
* The spelling a dictionary is asked about: a curly apostrophe (`’`), which keyboards and
|
|
6097
|
+
* pastes produce, straightened, since Hunspell dictionaries spell contractions with `'`.
|
|
6098
|
+
*/
|
|
6099
|
+
function spellingForm(word) {
|
|
6100
|
+
return word.replace(/’/g, "'");
|
|
6101
|
+
}
|
|
6102
|
+
/** Longer than any real word; a line this long is not one. */
|
|
6103
|
+
var MAX_WORD_LENGTH = 100;
|
|
6104
|
+
/**
|
|
6105
|
+
* The word as the dictionary keeps it, or null when it cannot be one entry. A curly apostrophe is
|
|
6106
|
+
* straightened, the form the checker compares (`spelling/words.ts` → `spellingForm`), so a word
|
|
6107
|
+
* added from `don’t` also accepts `don't`.
|
|
6108
|
+
*/
|
|
6109
|
+
function normaliseDictionaryWord(raw) {
|
|
6110
|
+
const word = spellingForm(raw.trim().normalize("NFC"));
|
|
6111
|
+
if (word === "" || /\s/u.test(word) || word.length > MAX_WORD_LENGTH) return null;
|
|
6112
|
+
return word;
|
|
6113
|
+
}
|
|
6114
|
+
/** Well-formed words, deduped (first seen wins), capped: tolerant of a peer's or a newer client's list. */
|
|
6115
|
+
function sanitizeDictionaryWords(raw) {
|
|
6116
|
+
if (!Array.isArray(raw)) return [];
|
|
6117
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6118
|
+
for (const entry of raw) {
|
|
6119
|
+
if (typeof entry !== "string") continue;
|
|
6120
|
+
const word = normaliseDictionaryWord(entry);
|
|
6121
|
+
if (word === null || seen.has(word)) continue;
|
|
6122
|
+
seen.add(word);
|
|
6123
|
+
if (seen.size >= 5e4) break;
|
|
6124
|
+
}
|
|
6125
|
+
return [...seen];
|
|
6126
|
+
}
|
|
6022
6127
|
//#endregion
|
|
6023
6128
|
//#region ../client/src/lib/storage/fs/frontmatter.ts
|
|
6024
6129
|
/**
|
|
@@ -6391,6 +6496,12 @@ function createGraphSync(deps) {
|
|
|
6391
6496
|
let socket;
|
|
6392
6497
|
let open = false;
|
|
6393
6498
|
let disposed = false;
|
|
6499
|
+
/**
|
|
6500
|
+
* Set when the Sync Server turned out to speak another protocol version. The session then
|
|
6501
|
+
* stops for good: every reconnect would meet the same server, and nothing either side
|
|
6502
|
+
* sends can be read by the other. Pending appends stay in the cache for a later session.
|
|
6503
|
+
*/
|
|
6504
|
+
let protocolMismatch;
|
|
6394
6505
|
const reportError = (error) => {
|
|
6395
6506
|
if (!disposed) deps.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
6396
6507
|
};
|
|
@@ -6412,6 +6523,7 @@ function createGraphSync(deps) {
|
|
|
6412
6523
|
function waitForCurrentConnection() {
|
|
6413
6524
|
if (open && socket) return Promise.resolve();
|
|
6414
6525
|
if (disposed) return Promise.reject(/* @__PURE__ */ new Error("the graph sync session was disposed"));
|
|
6526
|
+
if (protocolMismatch) return Promise.reject(protocolMismatch);
|
|
6415
6527
|
return new Promise((resolve, reject) => {
|
|
6416
6528
|
connectionWaiters.add({
|
|
6417
6529
|
resolve,
|
|
@@ -6451,7 +6563,7 @@ function createGraphSync(deps) {
|
|
|
6451
6563
|
}
|
|
6452
6564
|
/**
|
|
6453
6565
|
* Documents the CURRENT socket has subscribed to. The relay keeps and forwards presence
|
|
6454
|
-
* only for a subscribed document
|
|
6566
|
+
* only for a subscribed document, and this mirrors that rule at the source:
|
|
6455
6567
|
* y-protocols renews every engine's awareness state every fifteen seconds, and an engine
|
|
6456
6568
|
* created for a background walk starts with an empty `{}` state, so without the gate every
|
|
6457
6569
|
* unretained engine encrypted and sent presence the relay would only drop. Cleared with
|
|
@@ -6469,9 +6581,9 @@ function createGraphSync(deps) {
|
|
|
6469
6581
|
}
|
|
6470
6582
|
/**
|
|
6471
6583
|
* Subscribe to every retained document on a freshly opened socket, in batches the relay
|
|
6472
|
-
* accepts.
|
|
6473
|
-
*
|
|
6474
|
-
*
|
|
6584
|
+
* accepts. Above the protocol's per-message limit the relay rejects a subscribe as invalid,
|
|
6585
|
+
* which would leave a tab with many open views silently without live updates after a
|
|
6586
|
+
* reconnect.
|
|
6475
6587
|
*/
|
|
6476
6588
|
function subscribeRetained() {
|
|
6477
6589
|
const docIds = [...retained.keys()];
|
|
@@ -6609,6 +6721,7 @@ function createGraphSync(deps) {
|
|
|
6609
6721
|
if (deps.onRegistryChange) registryMap.observe(() => deps.onRegistryChange?.());
|
|
6610
6722
|
const metaMap = root.doc.getMap("meta");
|
|
6611
6723
|
const quickNotesArray = root.doc.getArray("quickNotes");
|
|
6724
|
+
const dictionaryMap = root.doc.getMap("spellingDictionary");
|
|
6612
6725
|
const themesMap = root.doc.getMap("themes");
|
|
6613
6726
|
/** A stored theme as a plain object, or null when the entry is not one. */
|
|
6614
6727
|
function readTheme(id) {
|
|
@@ -6662,9 +6775,23 @@ function createGraphSync(deps) {
|
|
|
6662
6775
|
publishNameOnceCaughtUp();
|
|
6663
6776
|
}, () => {});
|
|
6664
6777
|
}
|
|
6778
|
+
/** Report once, fail everything waiting for a connection, and close without reconnecting. */
|
|
6779
|
+
function stopForProtocolMismatch(serverVersion) {
|
|
6780
|
+
if (protocolMismatch) return;
|
|
6781
|
+
protocolMismatch = new SyncProtocolMismatchError(serverVersion);
|
|
6782
|
+
reportError(protocolMismatch);
|
|
6783
|
+
for (const waiter of connectionWaiters) waiter.reject(protocolMismatch);
|
|
6784
|
+
connectionWaiters.clear();
|
|
6785
|
+
socket?.close();
|
|
6786
|
+
}
|
|
6665
6787
|
function handleMessage(raw) {
|
|
6666
|
-
const
|
|
6667
|
-
if (
|
|
6788
|
+
const read = readServerMessage(raw);
|
|
6789
|
+
if (read.kind === "protocol_mismatch") {
|
|
6790
|
+
stopForProtocolMismatch(read.serverVersion);
|
|
6791
|
+
return;
|
|
6792
|
+
}
|
|
6793
|
+
if (read.kind === "malformed") return;
|
|
6794
|
+
const message = read.message;
|
|
6668
6795
|
if (message.type === "catchup_batch") performanceRecorder.mark("sync.catchup.batch", {
|
|
6669
6796
|
rows: message.updates.length,
|
|
6670
6797
|
bytes: new TextEncoder().encode(raw).byteLength,
|
|
@@ -6710,7 +6837,7 @@ function createGraphSync(deps) {
|
|
|
6710
6837
|
* on open. Only rebuildable snapshot uploads use a volatile queue.
|
|
6711
6838
|
*/
|
|
6712
6839
|
function connect() {
|
|
6713
|
-
if (disposed) return;
|
|
6840
|
+
if (disposed || protocolMismatch) return;
|
|
6714
6841
|
deps.token().then((token) => {
|
|
6715
6842
|
if (disposed) return;
|
|
6716
6843
|
const s = deps.connect(`${deps.relayUrl}?token=${encodeURIComponent(token)}`);
|
|
@@ -6752,7 +6879,7 @@ function createGraphSync(deps) {
|
|
|
6752
6879
|
pending.reject(new WatermarkConnectionInterruptedError());
|
|
6753
6880
|
}
|
|
6754
6881
|
watermarkRequests.clear();
|
|
6755
|
-
if (!disposed) setTimeout(connect, RECONNECT_MS);
|
|
6882
|
+
if (!disposed && !protocolMismatch) setTimeout(connect, RECONNECT_MS);
|
|
6756
6883
|
});
|
|
6757
6884
|
}, () => {
|
|
6758
6885
|
if (!disposed) setTimeout(connect, TOKEN_RETRY_MS);
|
|
@@ -6859,6 +6986,21 @@ function createGraphSync(deps) {
|
|
|
6859
6986
|
return () => quickNotesArray.unobserve(listener);
|
|
6860
6987
|
}
|
|
6861
6988
|
}),
|
|
6989
|
+
spellingDictionary: () => ({
|
|
6990
|
+
list: () => sanitizeDictionaryWords([...dictionaryMap.keys()]),
|
|
6991
|
+
add(word) {
|
|
6992
|
+
dictionaryMap.set(word, true);
|
|
6993
|
+
},
|
|
6994
|
+
remove(words) {
|
|
6995
|
+
root.doc.transact(() => {
|
|
6996
|
+
for (const word of words) dictionaryMap.delete(word);
|
|
6997
|
+
});
|
|
6998
|
+
},
|
|
6999
|
+
observe(listener) {
|
|
7000
|
+
dictionaryMap.observe(listener);
|
|
7001
|
+
return () => dictionaryMap.unobserve(listener);
|
|
7002
|
+
}
|
|
7003
|
+
}),
|
|
6862
7004
|
themes: () => ({
|
|
6863
7005
|
list() {
|
|
6864
7006
|
const out = [];
|
|
@@ -9455,6 +9597,16 @@ function isCalendarDay(value) {
|
|
|
9455
9597
|
function isJournalConcept(concept) {
|
|
9456
9598
|
return isCalendarDay(concept);
|
|
9457
9599
|
}
|
|
9600
|
+
/**
|
|
9601
|
+
* Why a [[Page]] cannot be called `day`: a day is the name of that day's [[Journal Entry]].
|
|
9602
|
+
*
|
|
9603
|
+
* A page given one sits in `pages/` answering to the day, which is what an older version left
|
|
9604
|
+
* behind when a Draft for a date promoted to a page. Creating a page and renaming one both
|
|
9605
|
+
* refuse with this, on both backends, so the copy is the same wherever the user meets it.
|
|
9606
|
+
*/
|
|
9607
|
+
function dayIsNotAPageName(day) {
|
|
9608
|
+
return `“${day.trim()}” is a date, and a date is the name of that day's journal entry, so a page cannot be called that. Choose a different name.`;
|
|
9609
|
+
}
|
|
9458
9610
|
//#endregion
|
|
9459
9611
|
//#region ../client/src/lib/document/wikilink/rename.ts
|
|
9460
9612
|
/**
|
|
@@ -9736,11 +9888,17 @@ var RenameUnconfirmedError = class extends Error {
|
|
|
9736
9888
|
* journal-shaped [[Pageless Concept]] (a day nobody has written yet) is refused for the same
|
|
9737
9889
|
* reason: its name is its day whether or not the entry exists.
|
|
9738
9890
|
*
|
|
9891
|
+
* The same holds in the other direction: a page renamed TO a day is refused, rather than left
|
|
9892
|
+
* in `pages/` answering to the day or merged into its journal entry with a `title` block the
|
|
9893
|
+
* entry never carries (ADR 0056).
|
|
9894
|
+
*
|
|
9739
9895
|
* A collision is NOT a refusal (ADR 0038 §4): it is a [[Merge]], confirmed in the dialog.
|
|
9740
9896
|
*/
|
|
9741
9897
|
function renameRefusal(options) {
|
|
9742
|
-
|
|
9898
|
+
const to = options.to.trim();
|
|
9899
|
+
if (to === "") return "A page needs a non-empty name.";
|
|
9743
9900
|
if (options.kind === "journal" || options.kind === null && isJournalConcept(options.from)) return "A journal entry cannot be renamed - its name is its date, and there is exactly one per day.";
|
|
9901
|
+
if (options.kind === "page" && isJournalConcept(to)) return dayIsNotAPageName(to);
|
|
9744
9902
|
return null;
|
|
9745
9903
|
}
|
|
9746
9904
|
/** Every step, direct first - what an applier iterates. */
|
|
@@ -9896,17 +10054,29 @@ function reconcileDecision({ dirty, baseText, diskText }) {
|
|
|
9896
10054
|
//#endregion
|
|
9897
10055
|
//#region ../client/src/lib/storage/fs/scan.ts
|
|
9898
10056
|
var SCANNED_SUBDIRS = ["journals", "pages"];
|
|
9899
|
-
|
|
10057
|
+
/** Whether a listed name is a document file: `.md`, in any case. A `.crswap` swap file is not. */
|
|
10058
|
+
function isDocumentFile(name) {
|
|
9900
10059
|
return /\.md$/i.test(name);
|
|
9901
10060
|
}
|
|
9902
|
-
|
|
10061
|
+
function fileKey(subdir, fileName) {
|
|
10062
|
+
return `${subdir}/${fileName}`;
|
|
10063
|
+
}
|
|
10064
|
+
async function scanGraph(adapter, options = {}) {
|
|
10065
|
+
const previous = /* @__PURE__ */ new Map();
|
|
10066
|
+
for (const entry of options.previous ?? []) previous.set(fileKey(entry.subdir, entry.fileName), entry);
|
|
10067
|
+
const writeInFlight = options.writeInFlight ?? (() => false);
|
|
9903
10068
|
const journals = [];
|
|
9904
10069
|
const pages = [];
|
|
9905
10070
|
for (const subdir of SCANNED_SUBDIRS) {
|
|
9906
10071
|
const kind = documentKindOf(subdir);
|
|
9907
10072
|
if (!kind) continue;
|
|
9908
10073
|
for (const { name, lastModified, size } of await adapter.list(subdir)) {
|
|
9909
|
-
if (!
|
|
10074
|
+
if (!isDocumentFile(name)) continue;
|
|
10075
|
+
const known = previous.get(fileKey(subdir, name));
|
|
10076
|
+
if (known !== void 0 && (known.lastModified === lastModified && known.size === size || writeInFlight(subdir, name))) {
|
|
10077
|
+
(kind === "journal" ? journals : pages).push(known);
|
|
10078
|
+
continue;
|
|
10079
|
+
}
|
|
9910
10080
|
const { text } = await adapter.read(subdir, name);
|
|
9911
10081
|
const fm = parseFrontmatter(text);
|
|
9912
10082
|
const concept = kind === "journal" ? journalConceptOf(name) : conceptOf$1(fm, fileStem(name));
|
|
@@ -9939,7 +10109,7 @@ async function scanGraph(adapter) {
|
|
|
9939
10109
|
* debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
|
|
9940
10110
|
* any browser code exists.
|
|
9941
10111
|
*
|
|
9942
|
-
* Async-seam note
|
|
10112
|
+
* Async-seam note: the seam's
|
|
9943
10113
|
* `getText()` is synchronous but disk reads are async, so `open()` returns a
|
|
9944
10114
|
* handle whose buffer is empty on first open and is hydrated by an internal
|
|
9945
10115
|
* awaited read that then notifies subscribers the *external* way — which is safe
|
|
@@ -10045,9 +10215,22 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10045
10215
|
open.set(doc.key, doc);
|
|
10046
10216
|
for (const listener of documentRenamed) listener(from, entry.concept);
|
|
10047
10217
|
}
|
|
10048
|
-
/**
|
|
10218
|
+
/**
|
|
10219
|
+
* Replace the registry from a fresh scan; fire onDocumentsChanged iff it changed. The scan
|
|
10220
|
+
* is given the entries it has and reuses each whose file has not moved, so a pass over an
|
|
10221
|
+
* unchanged graph reads nothing, and it is told which files have a write in flight so it
|
|
10222
|
+
* never opens one the store is saving - on Windows that read handle would make the
|
|
10223
|
+
* browser's rename of its swap file over the target fail. A fresh store has no entries, so
|
|
10224
|
+
* graph open reads every file and learns every identity.
|
|
10225
|
+
*/
|
|
10049
10226
|
async function refreshRegistry() {
|
|
10050
|
-
const entries = await scanGraph(adapter
|
|
10227
|
+
const entries = await scanGraph(adapter, {
|
|
10228
|
+
previous: registry.values(),
|
|
10229
|
+
writeInFlight: (subdir, fileName) => {
|
|
10230
|
+
for (const doc of open.values()) if (doc.saving && doc.subdir === subdir && doc.fileName === fileName) return true;
|
|
10231
|
+
return false;
|
|
10232
|
+
}
|
|
10233
|
+
});
|
|
10051
10234
|
registry.clear();
|
|
10052
10235
|
for (const entry of entries) registry.set(entry.key, entry);
|
|
10053
10236
|
const sig = registrySignature(entries);
|
|
@@ -10490,6 +10673,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10490
10673
|
async createPage(title, body = "") {
|
|
10491
10674
|
const concept = title.trim();
|
|
10492
10675
|
if (concept === "") throw new Error("A page needs a non-empty title.");
|
|
10676
|
+
if (isJournalConcept(concept)) throw new Error(dayIsNotAPageName(concept));
|
|
10493
10677
|
const key = conceptKey(concept);
|
|
10494
10678
|
if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
|
|
10495
10679
|
await adapter.ensureSkeleton();
|
|
@@ -10610,6 +10794,7 @@ function describeFilesystemSaveFailure(error, concept) {
|
|
|
10610
10794
|
case "NoModificationAllowedError": return `${opening} The file is locked by another program - a sync tool, or an editor holding it open. Close that, then retry. ${KEPT}`;
|
|
10611
10795
|
case "NotFoundError": return `${opening} The folder is no longer where it was - a drive unplugged, or the folder moved. Make it available again, then retry. ${KEPT}`;
|
|
10612
10796
|
case "NotReadableError": return `${opening} The file could not be read when it was opened, so nothing is written over it. Once it can be read the app reloads it, or asks you to choose if you have typed since. ${KEPT}`;
|
|
10797
|
+
case "InvalidStateError": return `${opening} Another program was using the file at the same time. Retry in a moment. If it keeps happening, check what else is using this folder. ${KEPT}`;
|
|
10613
10798
|
default: return `${opening}${error instanceof Error && error.message ? ` The browser reported: ${error.message}.` : ""} Retry in a moment. ${KEPT}`;
|
|
10614
10799
|
}
|
|
10615
10800
|
}
|
|
@@ -11346,6 +11531,7 @@ function createServerDocumentStore(graph, options) {
|
|
|
11346
11531
|
return day;
|
|
11347
11532
|
},
|
|
11348
11533
|
async createPage(title, body = "") {
|
|
11534
|
+
if (isJournalConcept(title)) throw new Error(dayIsNotAPageName(title));
|
|
11349
11535
|
if (docIdFor(title)) throw new Error(`A page for "${title}" already exists`);
|
|
11350
11536
|
const docId = crypto.randomUUID();
|
|
11351
11537
|
registry.set(docId, {
|
|
@@ -12227,17 +12413,22 @@ async function openHeadlessFolder(deps) {
|
|
|
12227
12413
|
});
|
|
12228
12414
|
const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
|
|
12229
12415
|
/**
|
|
12230
|
-
* The folder's listing as one string: every document file's name, mtime and size.
|
|
12231
|
-
*
|
|
12232
|
-
*
|
|
12233
|
-
*
|
|
12234
|
-
*
|
|
12235
|
-
*
|
|
12416
|
+
* The folder's listing as one string: every document file's name, mtime and size. A pass
|
|
12417
|
+
* first lists the two document subdirectories - a stat per file, no reads - and runs the
|
|
12418
|
+
* store's reconcile only when this differs from the last pass, so a pass before every
|
|
12419
|
+
* tool call on a large graph costs nothing when nothing moved. Only document files count:
|
|
12420
|
+
* a browser saving into the same folder writes a `.crswap` swap file first and renames it
|
|
12421
|
+
* over the document when the save lands, and a pass started by the swap file would read
|
|
12422
|
+
* the documents while that rename is due, which on Windows makes the browser's save fail.
|
|
12423
|
+
* An edit that keeps both mtime and size (an mtime-preserving copy) is missed until
|
|
12236
12424
|
* something else changes, the same blind spot the store's own fast path accepts.
|
|
12237
12425
|
*/
|
|
12238
12426
|
const listingSignature = async () => {
|
|
12239
12427
|
const parts = [];
|
|
12240
|
-
for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir))
|
|
12428
|
+
for (const subdir of ["journals", "pages"]) for (const entry of await deps.adapter.list(subdir)) {
|
|
12429
|
+
if (!isDocumentFile(entry.name)) continue;
|
|
12430
|
+
parts.push(`${subdir}/${entry.name}@${entry.lastModified}:${entry.size}`);
|
|
12431
|
+
}
|
|
12241
12432
|
return parts.sort().join("|");
|
|
12242
12433
|
};
|
|
12243
12434
|
let lastListing;
|
|
@@ -13318,31 +13509,48 @@ function archiveHtml(journals) {
|
|
|
13318
13509
|
return `<ul class="journal-archive">${journals.map((j) => `<li><time datetime="${escapeHtml$6(j.date ?? "")}">${escapeHtml$6(j.date ?? "")}</time> <a href="${escapeHtml$6(j.url)}">${escapeHtml$6(j.title)}</a>${j.excerpt ? `<p>${escapeHtml$6(j.excerpt)}</p>` : ""}</li>`).join("")}</ul>`;
|
|
13319
13510
|
}
|
|
13320
13511
|
//#endregion
|
|
13321
|
-
//#region ../client/src/lib/document/
|
|
13322
|
-
|
|
13323
|
-
|
|
13324
|
-
|
|
13512
|
+
//#region ../client/src/lib/document/code-languages.ts
|
|
13513
|
+
/**
|
|
13514
|
+
* The grammar for a [[Fenced Code Block]]'s info-string, from the registry the editor nests inside
|
|
13515
|
+
* fences (`@codemirror/language-data`, see `view/augmentations/code-highlight.ts`). Every surface
|
|
13516
|
+
* that highlights code outside an editor - the publisher (`publish/highlight.ts`) and the
|
|
13517
|
+
* read-only quotes (`code-tokens.ts`) - resolves a language here, so each knows the languages
|
|
13518
|
+
* the editor knows, by the same names and aliases.
|
|
13519
|
+
*
|
|
13520
|
+
* The match is exact: a language's name or one of its aliases, in any case. The editor's own
|
|
13521
|
+
* lookup (`@codemirror/lang-markdown`) also matches an alias found inside the info-string, which
|
|
13522
|
+
* reads `text`, `plaintext` and `context` as LaTeX (alias `tex`), so a plain-text block turned
|
|
13523
|
+
* into a LaTeX one, `%` starting a comment. That fuzzy step is not repeated here.
|
|
13524
|
+
*/
|
|
13325
13525
|
var loaded = /* @__PURE__ */ new Map();
|
|
13326
|
-
function describe(lang) {
|
|
13327
|
-
const byName = languages.find((d) => d.name.toLowerCase() === lang.toLowerCase());
|
|
13328
|
-
if (byName) return byName;
|
|
13329
|
-
return languages.find((d) => d.alias.some((a) => a.toLowerCase() === lang.toLowerCase())) ?? null;
|
|
13330
|
-
}
|
|
13331
13526
|
/** The grammar for an info-string, loaded once; null for a language the editor does not know either. */
|
|
13332
|
-
async function
|
|
13527
|
+
async function loadCodeLanguage(lang) {
|
|
13333
13528
|
const key = lang.toLowerCase();
|
|
13334
13529
|
let pending = loaded.get(key);
|
|
13335
13530
|
if (!pending) {
|
|
13336
|
-
const description =
|
|
13531
|
+
const description = LanguageDescription.matchLanguageName(languages, key, false);
|
|
13337
13532
|
pending = description ? description.load().catch(() => null) : Promise.resolve(null);
|
|
13338
13533
|
loaded.set(key, pending);
|
|
13339
13534
|
}
|
|
13340
13535
|
return pending;
|
|
13341
13536
|
}
|
|
13537
|
+
//#endregion
|
|
13538
|
+
//#region ../client/src/lib/document/publish/highlight.ts
|
|
13539
|
+
/**
|
|
13540
|
+
* Code highlighting for the site with the grammars the editor already ships: the same
|
|
13541
|
+
* `@codemirror/language-data` registry `code-highlight.ts` nests inside fences, resolved by
|
|
13542
|
+
* `code-languages.ts`, run headless over the fence's text and emitted as `<span class="tok-…">`
|
|
13543
|
+
* (the `classHighlighter` names), so a site knows exactly the languages the editor knows and needs
|
|
13544
|
+
* no script for it. A theme colours the `tok-*` classes. Unknown languages come back as null and
|
|
13545
|
+
* render escaped.
|
|
13546
|
+
*/
|
|
13547
|
+
function escapeHtml$5(text) {
|
|
13548
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
13549
|
+
}
|
|
13342
13550
|
/** The fence's code as highlighted HTML (the `<code>` element's inner HTML), or null when the language is unknown. */
|
|
13343
13551
|
async function highlightCode(lang, code) {
|
|
13344
13552
|
if (lang === "") return null;
|
|
13345
|
-
const support = await
|
|
13553
|
+
const support = await loadCodeLanguage(lang);
|
|
13346
13554
|
if (!support) return null;
|
|
13347
13555
|
const tree = support.language.parser.parse(code);
|
|
13348
13556
|
let out = "";
|
|
@@ -15516,7 +15724,7 @@ async function backlinks(graph, concept) {
|
|
|
15516
15724
|
kind: ref.kind,
|
|
15517
15725
|
line: ref.line,
|
|
15518
15726
|
breadcrumb: ref.breadcrumb,
|
|
15519
|
-
text: ref.kind === "block" ? ref.subtree.map((node) => node.
|
|
15727
|
+
text: ref.kind === "block" ? ref.subtree.map((node) => node.text).join("\n") : ref.context?.text ?? ""
|
|
15520
15728
|
}))
|
|
15521
15729
|
}))
|
|
15522
15730
|
};
|