@appsoftwareltd/etherpk-mcp 0.8.2 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +678 -233
- package/dist/main.js.map +1 -1
- package/package.json +3 -3
package/dist/main.js
CHANGED
|
@@ -20,7 +20,7 @@ import * as Y from "yjs";
|
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
|
|
22
22
|
import * as encoding from "lib0/encoding";
|
|
23
|
-
import { parse,
|
|
23
|
+
import { Document, Scalar, YAMLMap, isMap, isScalar, isSeq, parse, parseDocument, visit } from "yaml";
|
|
24
24
|
import "fake-indexeddb/auto";
|
|
25
25
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
26
26
|
import { classHighlighter, highlightTree } from "@lezer/highlight";
|
|
@@ -34,7 +34,7 @@ import { request } from "node:https";
|
|
|
34
34
|
import { BlockList, isIP } from "node:net";
|
|
35
35
|
var package_default = {
|
|
36
36
|
name: "@appsoftwareltd/etherpk-mcp",
|
|
37
|
-
version: "0.8.
|
|
37
|
+
version: "0.8.3",
|
|
38
38
|
license: "Elastic-2.0",
|
|
39
39
|
description: "EtherPK Headless Client: an MCP server over a synced knowledge graph, run beside the agent on the user's own machine.",
|
|
40
40
|
type: "module",
|
|
@@ -2511,6 +2511,119 @@ function snippetSegments(snippet) {
|
|
|
2511
2511
|
}
|
|
2512
2512
|
return segments.filter((s) => s.text !== "");
|
|
2513
2513
|
}
|
|
2514
|
+
/** Letters, digits, `_`, `.` and `-`: a key a person can type, dot paths included. */
|
|
2515
|
+
var PROPERTY_KEY_PATTERN = /^[\p{L}\p{N}_][\p{L}\p{N}_.-]*$/u;
|
|
2516
|
+
/**
|
|
2517
|
+
* Whitespace-separated tokens. A quote opens a run that spaces do not end, whether it starts the
|
|
2518
|
+
* token (`"a phrase"`) or follows a key (`status:"in progress"`); an unclosed one runs to the end.
|
|
2519
|
+
*/
|
|
2520
|
+
function searchTokens(input) {
|
|
2521
|
+
const out = [];
|
|
2522
|
+
let index = 0;
|
|
2523
|
+
while (index < input.length) {
|
|
2524
|
+
if (/\s/.test(input[index])) {
|
|
2525
|
+
index++;
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
const from = index;
|
|
2529
|
+
while (index < input.length && !/\s/.test(input[index])) {
|
|
2530
|
+
if (input[index] === "\"") {
|
|
2531
|
+
const close = input.indexOf("\"", index + 1);
|
|
2532
|
+
index = close === -1 ? input.length : close + 1;
|
|
2533
|
+
continue;
|
|
2534
|
+
}
|
|
2535
|
+
index++;
|
|
2536
|
+
}
|
|
2537
|
+
out.push({
|
|
2538
|
+
text: input.slice(from, index),
|
|
2539
|
+
from,
|
|
2540
|
+
to: index
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
return out;
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* The filters in `input`, and the words left for name and text matching.
|
|
2547
|
+
*
|
|
2548
|
+
* A term is a filter only when its key is one at least one searchable document carries
|
|
2549
|
+
* (`knownKeys`, lower-cased): anything else shaped like `x:y` stays words, which keeps a URL, a
|
|
2550
|
+
* time or `note:` in prose as text. A fully quoted term is always words. A known key with no
|
|
2551
|
+
* value yet (`status:` mid-typing) is dropped from both, so the key is never searched as a word
|
|
2552
|
+
* a moment before the value arrives.
|
|
2553
|
+
*/
|
|
2554
|
+
function parseSearchQuery(input, knownKeys) {
|
|
2555
|
+
const filters = [];
|
|
2556
|
+
const terms = [];
|
|
2557
|
+
const cut = [];
|
|
2558
|
+
if (knownKeys.size > 0) for (const token of searchTokens(input)) {
|
|
2559
|
+
if (token.text.startsWith("\"")) continue;
|
|
2560
|
+
const colon = token.text.indexOf(":");
|
|
2561
|
+
if (colon <= 0) continue;
|
|
2562
|
+
const negated = token.text.startsWith("-");
|
|
2563
|
+
const key = token.text.slice(negated ? 1 : 0, colon);
|
|
2564
|
+
if (!PROPERTY_KEY_PATTERN.test(key) || !knownKeys.has(key.toLowerCase())) continue;
|
|
2565
|
+
const filter = filterValue(key, token.text.slice(colon + 1), negated);
|
|
2566
|
+
cut.push({
|
|
2567
|
+
from: token.from,
|
|
2568
|
+
to: token.to
|
|
2569
|
+
});
|
|
2570
|
+
if (filter === null) continue;
|
|
2571
|
+
filters.push(filter);
|
|
2572
|
+
terms.push({
|
|
2573
|
+
filter,
|
|
2574
|
+
from: token.from,
|
|
2575
|
+
to: token.to
|
|
2576
|
+
});
|
|
2577
|
+
}
|
|
2578
|
+
return {
|
|
2579
|
+
words: withoutRanges(input, cut),
|
|
2580
|
+
filters,
|
|
2581
|
+
terms
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
/** The filter for a value as typed, or null when there is no value yet. */
|
|
2585
|
+
function filterValue(key, raw, negated) {
|
|
2586
|
+
if (raw === "*") return {
|
|
2587
|
+
key,
|
|
2588
|
+
value: null,
|
|
2589
|
+
prefix: false,
|
|
2590
|
+
negated
|
|
2591
|
+
};
|
|
2592
|
+
if (raw.startsWith("\"")) {
|
|
2593
|
+
const close = raw.indexOf("\"", 1);
|
|
2594
|
+
const value = (close === -1 ? raw.slice(1) : raw.slice(1, close)).trim();
|
|
2595
|
+
return value === "" ? null : {
|
|
2596
|
+
key,
|
|
2597
|
+
value,
|
|
2598
|
+
prefix: false,
|
|
2599
|
+
negated
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
if (raw.length > 1 && raw.endsWith("*")) return {
|
|
2603
|
+
key,
|
|
2604
|
+
value: raw.slice(0, -1),
|
|
2605
|
+
prefix: true,
|
|
2606
|
+
negated
|
|
2607
|
+
};
|
|
2608
|
+
return raw === "" ? null : {
|
|
2609
|
+
key,
|
|
2610
|
+
value: raw,
|
|
2611
|
+
prefix: false,
|
|
2612
|
+
negated
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
/** `input` with the ranges removed and the gaps they leave closed to single spaces. */
|
|
2616
|
+
function withoutRanges(input, ranges) {
|
|
2617
|
+
if (ranges.length === 0) return input;
|
|
2618
|
+
const parts = [];
|
|
2619
|
+
let at = 0;
|
|
2620
|
+
for (const range of ranges) {
|
|
2621
|
+
parts.push(input.slice(at, range.from));
|
|
2622
|
+
at = range.to;
|
|
2623
|
+
}
|
|
2624
|
+
parts.push(input.slice(at));
|
|
2625
|
+
return parts.map((part) => part.trim()).filter((part) => part !== "").join(" ");
|
|
2626
|
+
}
|
|
2514
2627
|
//#endregion
|
|
2515
2628
|
//#region ../client/src/lib/document/index-db.ts
|
|
2516
2629
|
/**
|
|
@@ -2572,6 +2685,15 @@ CREATE TABLE IF NOT EXISTS passages (
|
|
|
2572
2685
|
page_id INTEGER NOT NULL, ord INTEGER NOT NULL, start_line INTEGER NOT NULL,
|
|
2573
2686
|
end_line INTEGER NOT NULL, first_block_local_id INTEGER NOT NULL,
|
|
2574
2687
|
content_hash TEXT NOT NULL, text TEXT NOT NULL);
|
|
2688
|
+
-- [[Property]] rows for [[Property Filter]]s (ADR 0107): one per value, keys as dot paths. key and
|
|
2689
|
+
-- value keep their spelling for display; key_lc and value_lc are lower-cased in JS, because
|
|
2690
|
+
-- SQLite's lower() folds ASCII only and a filter matches ignoring case in any script. value is
|
|
2691
|
+
-- NULL on a mapping's presence row. title and aliases rows come from the graph's names. A
|
|
2692
|
+
-- protected document has none.
|
|
2693
|
+
CREATE TABLE IF NOT EXISTS properties (
|
|
2694
|
+
page_id INTEGER NOT NULL, key TEXT NOT NULL, key_lc TEXT NOT NULL,
|
|
2695
|
+
value TEXT, value_lc TEXT);
|
|
2696
|
+
CREATE INDEX IF NOT EXISTS properties_key ON properties(key_lc, value_lc);
|
|
2575
2697
|
CREATE INDEX IF NOT EXISTS passages_hash ON passages(content_hash);
|
|
2576
2698
|
CREATE INDEX IF NOT EXISTS links_concept_key ON links(concept_key);
|
|
2577
2699
|
CREATE INDEX IF NOT EXISTS blocks_page ON blocks(page_id);
|
|
@@ -2620,7 +2742,7 @@ function createSchema(db) {
|
|
|
2620
2742
|
db.exec(SCHEMA$1);
|
|
2621
2743
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('active_generation', 1)");
|
|
2622
2744
|
db.run("INSERT OR IGNORE INTO index_metadata (key, value) VALUES ('revision', 0)");
|
|
2623
|
-
db.exec(`PRAGMA user_version =
|
|
2745
|
+
db.exec(`PRAGMA user_version = 14`);
|
|
2624
2746
|
}
|
|
2625
2747
|
function activeIndexGeneration(db) {
|
|
2626
2748
|
return db.all("SELECT value FROM index_metadata WHERE key = 'active_generation'")[0]?.value ?? 1;
|
|
@@ -2641,7 +2763,7 @@ function advanceIndexRevision(db) {
|
|
|
2641
2763
|
*/
|
|
2642
2764
|
function isUsableIndex(db) {
|
|
2643
2765
|
try {
|
|
2644
|
-
return db.all("PRAGMA user_version")[0]?.user_version ===
|
|
2766
|
+
return db.all("PRAGMA user_version")[0]?.user_version === 14;
|
|
2645
2767
|
} catch {
|
|
2646
2768
|
return false;
|
|
2647
2769
|
}
|
|
@@ -2661,8 +2783,11 @@ function documentHash(db, key) {
|
|
|
2661
2783
|
*/
|
|
2662
2784
|
function indexDocHash(doc) {
|
|
2663
2785
|
const includes = doc.includes ?? [];
|
|
2664
|
-
|
|
2665
|
-
|
|
2786
|
+
const properties = doc.properties ?? [];
|
|
2787
|
+
if (includes.length === 0 && properties.length === 0) return hashText(doc.text);
|
|
2788
|
+
const includeText = includes.map((i) => `${i.publication}\u0001${i.slot}\u0001${i.concept}`).join("\0");
|
|
2789
|
+
const propertyText = properties.map((p) => `${p.key}\u0001${p.value ?? ""}`).join("\0");
|
|
2790
|
+
return hashText(`${doc.text}\u0000${includeText}\u0003${propertyText}`);
|
|
2666
2791
|
}
|
|
2667
2792
|
function hashText(text) {
|
|
2668
2793
|
let h1 = 3735928559;
|
|
@@ -2703,6 +2828,7 @@ function insertDerived(db, pageId, doc) {
|
|
|
2703
2828
|
conceptKey$1(alias),
|
|
2704
2829
|
alias
|
|
2705
2830
|
]);
|
|
2831
|
+
insertProperties(db, pageId, doc);
|
|
2706
2832
|
for (const include of doc.includes ?? []) db.run("INSERT INTO publication_includes (page_id, publication_id, slot, concept_key, concept) VALUES (?,?,?,?,?)", [
|
|
2707
2833
|
pageId,
|
|
2708
2834
|
include.publication,
|
|
@@ -2779,12 +2905,40 @@ function insertDerived(db, pageId, doc) {
|
|
|
2779
2905
|
return searchable;
|
|
2780
2906
|
}
|
|
2781
2907
|
/**
|
|
2908
|
+
* Write one document's [[Property]] rows (ADR 0107): its name as `title`, each alias as
|
|
2909
|
+
* `aliases`, then what its Frontmatter carries. A [[Protected Document]] writes none. Its
|
|
2910
|
+
* Frontmatter is stored in plaintext, but no kind of matching in Search reaches protected
|
|
2911
|
+
* content, and a document with no rows cannot match a filter, not even a negated one, because
|
|
2912
|
+
* every filtered query also requires `protected = 0`.
|
|
2913
|
+
*/
|
|
2914
|
+
function insertProperties(db, pageId, doc) {
|
|
2915
|
+
if (protectedFlag(doc) === 1) return;
|
|
2916
|
+
const rows = [
|
|
2917
|
+
{
|
|
2918
|
+
key: "title",
|
|
2919
|
+
value: doc.concept
|
|
2920
|
+
},
|
|
2921
|
+
...doc.aliases.map((alias) => ({
|
|
2922
|
+
key: "aliases",
|
|
2923
|
+
value: alias
|
|
2924
|
+
})),
|
|
2925
|
+
...doc.properties ?? []
|
|
2926
|
+
];
|
|
2927
|
+
for (const row of rows) db.run("INSERT INTO properties (page_id, key, key_lc, value, value_lc) VALUES (?,?,?,?,?)", [
|
|
2928
|
+
pageId,
|
|
2929
|
+
row.key,
|
|
2930
|
+
row.key.toLowerCase(),
|
|
2931
|
+
row.value,
|
|
2932
|
+
row.value === null ? null : row.value.toLowerCase()
|
|
2933
|
+
]);
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2782
2936
|
* Write one document's [[Task Concept]] rows (ADR 0051).
|
|
2783
2937
|
*
|
|
2784
2938
|
* Derivation supplies the wikilinked half; the document's own concept is added HERE, as the
|
|
2785
2939
|
* virtual root of every task's ancestry chain, because derivation reads text and never learns
|
|
2786
2940
|
* the document's identity. Without that row, filtering to a project page returns nothing for
|
|
2787
|
-
* the tasks written directly on it — the case the "
|
|
2941
|
+
* the tasks written directly on it — the case the "Filter to <active tab>" shortcut exists for.
|
|
2788
2942
|
*
|
|
2789
2943
|
* Only the document's own concept goes in, never its aliases: the query resolves a filtered
|
|
2790
2944
|
* name to its page and matches on every name that page answers to, so an alias row here would
|
|
@@ -2879,7 +3033,8 @@ function deleteGeneration(db, generation) {
|
|
|
2879
3033
|
"links",
|
|
2880
3034
|
"tasks",
|
|
2881
3035
|
"task_concepts",
|
|
2882
|
-
"passages"
|
|
3036
|
+
"passages",
|
|
3037
|
+
"properties"
|
|
2883
3038
|
]) db.run(`DELETE FROM ${table} WHERE page_id IN (SELECT id FROM pages WHERE generation = ?)`, [generation]);
|
|
2884
3039
|
db.run("DELETE FROM pages WHERE generation = ?", [generation]);
|
|
2885
3040
|
}
|
|
@@ -2954,6 +3109,7 @@ function ingestOne(db, doc) {
|
|
|
2954
3109
|
db.run("DELETE FROM tasks WHERE page_id = ?", [pageId]);
|
|
2955
3110
|
db.run("DELETE FROM task_concepts WHERE page_id = ?", [pageId]);
|
|
2956
3111
|
db.run("DELETE FROM passages WHERE page_id = ?", [pageId]);
|
|
3112
|
+
db.run("DELETE FROM properties WHERE page_id = ?", [pageId]);
|
|
2957
3113
|
db.run("UPDATE pages SET concept = ?, kind = ?, text_hash = ?, protected = ? WHERE id = ?", [
|
|
2958
3114
|
doc.concept,
|
|
2959
3115
|
doc.kind,
|
|
@@ -3366,25 +3522,27 @@ var SNIPPET_TOKENS = 24;
|
|
|
3366
3522
|
* scores merely reintroduces the long-document bias bm25 exists to remove. Counting is cruder,
|
|
3367
3523
|
* robust, and explainable in one sentence - and it orders by a number the row displays.
|
|
3368
3524
|
*/
|
|
3369
|
-
function searchText$1(db, query, offset, limit) {
|
|
3525
|
+
function searchText$1(db, query, offset, limit, filters = []) {
|
|
3370
3526
|
const match = buildFtsMatch(query);
|
|
3371
3527
|
if (match === null) return {
|
|
3372
3528
|
groups: [],
|
|
3373
3529
|
hasMore: false
|
|
3374
3530
|
};
|
|
3375
3531
|
const generation = activeIndexGeneration(db);
|
|
3532
|
+
const narrow = propertyFilterSql(filters);
|
|
3376
3533
|
const ranked = db.all(`WITH hits AS MATERIALIZED (
|
|
3377
3534
|
SELECT rowid / ${BLOCK_FTS_STRIDE} AS page_id, bm25(block_fts) AS score
|
|
3378
3535
|
FROM block_fts WHERE block_fts MATCH ?
|
|
3379
3536
|
)
|
|
3380
3537
|
SELECT h.page_id AS page_id, COUNT(*) AS matches, MIN(h.score) AS best
|
|
3381
3538
|
FROM hits h
|
|
3382
|
-
JOIN pages p ON p.id = h.page_id AND p.generation =
|
|
3539
|
+
JOIN pages p ON p.id = h.page_id AND p.generation = ?${narrow.sql}
|
|
3383
3540
|
GROUP BY h.page_id
|
|
3384
3541
|
ORDER BY matches DESC, best ASC, h.page_id ASC
|
|
3385
3542
|
LIMIT ? OFFSET ?`, [
|
|
3386
3543
|
match,
|
|
3387
3544
|
generation,
|
|
3545
|
+
...narrow.params,
|
|
3388
3546
|
limit + 1,
|
|
3389
3547
|
offset
|
|
3390
3548
|
]);
|
|
@@ -3462,21 +3620,23 @@ function searchBreadcrumb(blocks, localId) {
|
|
|
3462
3620
|
* Capped rather than exact because a prefix query - which is every query, mid-typing - can
|
|
3463
3621
|
* match most of the graph, and this runs beside the rows it must never delay.
|
|
3464
3622
|
*/
|
|
3465
|
-
function searchTextCount(db, query) {
|
|
3623
|
+
function searchTextCount(db, query, filters = []) {
|
|
3466
3624
|
const match = buildFtsMatch(query);
|
|
3467
3625
|
if (match === null) return {
|
|
3468
3626
|
total: 0,
|
|
3469
3627
|
capped: false
|
|
3470
3628
|
};
|
|
3629
|
+
const narrow = propertyFilterSql(filters);
|
|
3471
3630
|
const n = db.all(`SELECT COUNT(*) AS n FROM (
|
|
3472
3631
|
SELECT m.page_id FROM (
|
|
3473
3632
|
SELECT DISTINCT rowid / 1048576 AS page_id FROM block_fts
|
|
3474
3633
|
WHERE block_fts MATCH ?
|
|
3475
3634
|
) m
|
|
3476
|
-
JOIN pages p ON p.id = m.page_id AND p.generation =
|
|
3635
|
+
JOIN pages p ON p.id = m.page_id AND p.generation = ?${narrow.sql}
|
|
3477
3636
|
LIMIT ?)`, [
|
|
3478
3637
|
match,
|
|
3479
3638
|
activeIndexGeneration(db),
|
|
3639
|
+
...narrow.params,
|
|
3480
3640
|
1001
|
|
3481
3641
|
])[0]?.n ?? 0;
|
|
3482
3642
|
return n > 1e3 ? {
|
|
@@ -3487,6 +3647,106 @@ function searchTextCount(db, query) {
|
|
|
3487
3647
|
capped: false
|
|
3488
3648
|
};
|
|
3489
3649
|
}
|
|
3650
|
+
/** Documents a filtered name listing returns at most: well past any graph Search serves. */
|
|
3651
|
+
var PROPERTY_MATCH_CAP = 5e3;
|
|
3652
|
+
/**
|
|
3653
|
+
* The SQL that narrows a query over `pages AS p` to the documents passing every filter, as an
|
|
3654
|
+
* `AND …` suffix, with its parameters in order. Empty when there are no filters. A filtered
|
|
3655
|
+
* query never returns a [[Protected Document]], which has no property rows; the explicit
|
|
3656
|
+
* `protected = 0` is what keeps a NEGATED filter from matching one.
|
|
3657
|
+
*/
|
|
3658
|
+
function propertyFilterSql(filters) {
|
|
3659
|
+
if (filters.length === 0) return {
|
|
3660
|
+
sql: "",
|
|
3661
|
+
params: []
|
|
3662
|
+
};
|
|
3663
|
+
const clauses = ["p.protected = 0"];
|
|
3664
|
+
const params = [];
|
|
3665
|
+
for (const filter of filters) {
|
|
3666
|
+
let test = "x.key_lc = ?";
|
|
3667
|
+
params.push(filter.key.toLowerCase());
|
|
3668
|
+
if (filter.value !== null) {
|
|
3669
|
+
const value = filter.value.toLowerCase();
|
|
3670
|
+
if (filter.prefix) {
|
|
3671
|
+
test += " AND substr(x.value_lc, 1, ?) = ?";
|
|
3672
|
+
params.push(Array.from(value).length, value);
|
|
3673
|
+
} else {
|
|
3674
|
+
test += " AND x.value_lc = ?";
|
|
3675
|
+
params.push(value);
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
clauses.push(`${filter.negated ? "NOT " : ""}EXISTS (SELECT 1 FROM properties x WHERE x.page_id = p.id AND ${test})`);
|
|
3679
|
+
}
|
|
3680
|
+
return {
|
|
3681
|
+
sql: ` AND ${clauses.join(" AND ")}`,
|
|
3682
|
+
params
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
/**
|
|
3686
|
+
* Every key a searchable document carries, once per spelling, most used first: what decides
|
|
3687
|
+
* whether a typed `x:y` is a filter, and what the key suggestions offer.
|
|
3688
|
+
*/
|
|
3689
|
+
function propertyKeys(db) {
|
|
3690
|
+
return db.all(`SELECT pr.key AS key, COUNT(DISTINCT pr.page_id) AS documents
|
|
3691
|
+
FROM properties pr JOIN pages p ON p.id = pr.page_id
|
|
3692
|
+
WHERE p.generation = ?
|
|
3693
|
+
GROUP BY pr.key
|
|
3694
|
+
ORDER BY documents DESC, pr.key_lc, pr.key`, [activeIndexGeneration(db)]);
|
|
3695
|
+
}
|
|
3696
|
+
/** The values `key` has across the graph, grouped ignoring case, most used first. */
|
|
3697
|
+
function propertyValues(db, key, limit = 50) {
|
|
3698
|
+
return db.all(`SELECT MIN(pr.value) AS value, COUNT(DISTINCT pr.page_id) AS documents
|
|
3699
|
+
FROM properties pr JOIN pages p ON p.id = pr.page_id
|
|
3700
|
+
WHERE p.generation = ? AND pr.key_lc = ? AND pr.value IS NOT NULL
|
|
3701
|
+
GROUP BY pr.value_lc
|
|
3702
|
+
ORDER BY documents DESC, pr.value_lc
|
|
3703
|
+
LIMIT ?`, [
|
|
3704
|
+
activeIndexGeneration(db),
|
|
3705
|
+
key.toLowerCase(),
|
|
3706
|
+
limit
|
|
3707
|
+
]);
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Every document that passes all `filters`, in name order, with the values of the keys the
|
|
3711
|
+
* positive filters name (not `title`, which the row already shows). Empty without a filter:
|
|
3712
|
+
* listing the whole graph is [[All Documents]]' job.
|
|
3713
|
+
*/
|
|
3714
|
+
function documentsMatchingProperties(db, filters, limit = PROPERTY_MATCH_CAP) {
|
|
3715
|
+
if (filters.length === 0) return [];
|
|
3716
|
+
const narrow = propertyFilterSql(filters);
|
|
3717
|
+
const pages = db.all(`SELECT p.id AS id, p.concept AS concept, p.kind AS kind FROM pages p
|
|
3718
|
+
WHERE p.generation = ?${narrow.sql}
|
|
3719
|
+
ORDER BY p.concept_key
|
|
3720
|
+
LIMIT ?`, [
|
|
3721
|
+
activeIndexGeneration(db),
|
|
3722
|
+
...narrow.params,
|
|
3723
|
+
limit
|
|
3724
|
+
]);
|
|
3725
|
+
if (pages.length === 0) return [];
|
|
3726
|
+
const shown = [...new Set(filters.filter((f) => !f.negated).map((f) => f.key.toLowerCase()))].filter((key) => key !== "title");
|
|
3727
|
+
const values = /* @__PURE__ */ new Map();
|
|
3728
|
+
if (shown.length > 0) {
|
|
3729
|
+
const rows = db.all(`SELECT page_id, key, value FROM properties
|
|
3730
|
+
WHERE page_id IN (SELECT value FROM json_each(?))
|
|
3731
|
+
AND key_lc IN (SELECT value FROM json_each(?))
|
|
3732
|
+
AND value IS NOT NULL
|
|
3733
|
+
ORDER BY rowid`, [JSON.stringify(pages.map((page) => page.id)), JSON.stringify(shown)]);
|
|
3734
|
+
for (const row of rows) {
|
|
3735
|
+
const list = values.get(row.page_id);
|
|
3736
|
+
const entry = {
|
|
3737
|
+
key: row.key,
|
|
3738
|
+
value: row.value
|
|
3739
|
+
};
|
|
3740
|
+
if (list) list.push(entry);
|
|
3741
|
+
else values.set(row.page_id, [entry]);
|
|
3742
|
+
}
|
|
3743
|
+
}
|
|
3744
|
+
return pages.map((page) => ({
|
|
3745
|
+
concept: page.concept,
|
|
3746
|
+
kind: page.kind,
|
|
3747
|
+
properties: values.get(page.id) ?? []
|
|
3748
|
+
}));
|
|
3749
|
+
}
|
|
3490
3750
|
/** Escape a literal for a LIKE pattern, so an asset name can never behave as a wildcard. */
|
|
3491
3751
|
function likeLiteral(needle) {
|
|
3492
3752
|
return needle.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
@@ -4010,7 +4270,7 @@ function cacheRoot(env) {
|
|
|
4010
4270
|
* recovered from a joined path (see there).
|
|
4011
4271
|
*/
|
|
4012
4272
|
var CACHE_FILE_NAME = `local-cache.v${CACHE_DB_VERSION}.bin`;
|
|
4013
|
-
var INDEX_FILE_NAME = `index.
|
|
4273
|
+
var INDEX_FILE_NAME = `index.v14.sqlite`;
|
|
4014
4274
|
var VECTORS_FILE_NAME = `vectors.v1.sqlite`;
|
|
4015
4275
|
function cacheFile(dir) {
|
|
4016
4276
|
return join(dir, CACHE_FILE_NAME);
|
|
@@ -6396,6 +6656,172 @@ function parseFrontmatter(text) {
|
|
|
6396
6656
|
}
|
|
6397
6657
|
}
|
|
6398
6658
|
//#endregion
|
|
6659
|
+
//#region ../client/src/lib/document/frontmatter/frontmatter-yaml.ts
|
|
6660
|
+
/**
|
|
6661
|
+
* Reading, editing and writing a [[Frontmatter]] block's YAML in the one style EtherPK writes
|
|
6662
|
+
* (ADR 0108).
|
|
6663
|
+
*
|
|
6664
|
+
* Every writer goes through here: the identity write-back, publishing, the arbitrary-key patch,
|
|
6665
|
+
* the importers, Add frontmatter and the editor's tidy when an editing episode ends. Edits go
|
|
6666
|
+
* through the `yaml` library's document model rather than a plain object, so a comment, and any
|
|
6667
|
+
* key the edit does not touch, survives. The block is then written in the EtherPK style:
|
|
6668
|
+
*
|
|
6669
|
+
* - two-space indentation, a list under its key as ` - item`, one item per line;
|
|
6670
|
+
* - an inline list (`[a, b]`) written as a block list; an empty `[]` written bare on a key EtherPK
|
|
6671
|
+
* reads (`publications:`), where empty means not set, and kept on any other key;
|
|
6672
|
+
* - quotes only where a value needs them (`"true"` stays quoted, `"hello"` does not);
|
|
6673
|
+
* - no line wrapping, no blank lines, key order as found, comments kept;
|
|
6674
|
+
* - an empty value written bare (`slug:`), which reads as "fill this in".
|
|
6675
|
+
*
|
|
6676
|
+
* Restyling never changes what the block says: the value is compared before and after, and a
|
|
6677
|
+
* rewrite that would change it is dropped. A block that does not parse is never rewritten.
|
|
6678
|
+
*
|
|
6679
|
+
* Pure: no store, no editor. What counts as a block comes from `frontmatter-span.ts`.
|
|
6680
|
+
*/
|
|
6681
|
+
/** The `yaml` serialiser options that make up the EtherPK style. */
|
|
6682
|
+
var STYLE = {
|
|
6683
|
+
indent: 2,
|
|
6684
|
+
indentSeq: true,
|
|
6685
|
+
lineWidth: 0,
|
|
6686
|
+
minContentWidth: 0,
|
|
6687
|
+
nullStr: ""
|
|
6688
|
+
};
|
|
6689
|
+
/**
|
|
6690
|
+
* The block's YAML parsed as a document, or null when it does not parse or is not a mapping.
|
|
6691
|
+
* An empty block (or one holding only comments) is an empty mapping.
|
|
6692
|
+
*/
|
|
6693
|
+
function parseBlock(body) {
|
|
6694
|
+
const doc = parseDocument(body);
|
|
6695
|
+
if (doc.errors.length > 0) return null;
|
|
6696
|
+
if (doc.contents === null) {
|
|
6697
|
+
doc.contents = new YAMLMap();
|
|
6698
|
+
return doc;
|
|
6699
|
+
}
|
|
6700
|
+
return isMap(doc.contents) ? doc : null;
|
|
6701
|
+
}
|
|
6702
|
+
/** The block's YAML as a plain object, or null when it does not parse to a mapping. */
|
|
6703
|
+
function frontmatterData(body) {
|
|
6704
|
+
const doc = parseBlock(body);
|
|
6705
|
+
return doc === null ? null : doc.toJS();
|
|
6706
|
+
}
|
|
6707
|
+
/**
|
|
6708
|
+
* The keys EtherPK reads, on which an empty value means the key is not set (ADR 0108, point 5).
|
|
6709
|
+
* On these an empty `[]` and a bare key say the same thing, so the style writes it bare. Another
|
|
6710
|
+
* key's `[]` is kept: to a tool that reads it, an empty list and no value may differ.
|
|
6711
|
+
*/
|
|
6712
|
+
var NOT_SET_WHEN_EMPTY = new Set([
|
|
6713
|
+
"title",
|
|
6714
|
+
"aliases",
|
|
6715
|
+
"public",
|
|
6716
|
+
"publications",
|
|
6717
|
+
"slug",
|
|
6718
|
+
"date",
|
|
6719
|
+
"publication"
|
|
6720
|
+
]);
|
|
6721
|
+
/** Apply the EtherPK style to a document in place. */
|
|
6722
|
+
function applyStyle(doc) {
|
|
6723
|
+
visit(doc, {
|
|
6724
|
+
Pair(_, pair, path) {
|
|
6725
|
+
if (path.length !== 2) return;
|
|
6726
|
+
const key = isScalar(pair.key) ? pair.key.value : pair.key;
|
|
6727
|
+
if (typeof key === "string" && NOT_SET_WHEN_EMPTY.has(key) && isSeq(pair.value) && pair.value.items.length === 0) pair.value = new Scalar(null);
|
|
6728
|
+
},
|
|
6729
|
+
Scalar(_, node) {
|
|
6730
|
+
node.spaceBefore = false;
|
|
6731
|
+
if (typeof node.value === "string" && !node.value.includes("\n") && (node.type === "QUOTE_DOUBLE" || node.type === "QUOTE_SINGLE")) node.type = "PLAIN";
|
|
6732
|
+
},
|
|
6733
|
+
Map(_, node) {
|
|
6734
|
+
node.spaceBefore = false;
|
|
6735
|
+
if (node.items.length > 0) node.flow = false;
|
|
6736
|
+
},
|
|
6737
|
+
Seq(_, node) {
|
|
6738
|
+
node.spaceBefore = false;
|
|
6739
|
+
if (node.items.length > 0) node.flow = false;
|
|
6740
|
+
}
|
|
6741
|
+
});
|
|
6742
|
+
}
|
|
6743
|
+
/** The styled YAML of a document, ending in a newline, or '' when it has no keys and no comment. */
|
|
6744
|
+
function styledYaml(doc) {
|
|
6745
|
+
applyStyle(doc);
|
|
6746
|
+
const map = doc.contents;
|
|
6747
|
+
if (isMap(map) && map.items.length === 0) return doc.commentBefore ? `${doc.commentBefore.replace(/^/gm, "#")}\n` : "";
|
|
6748
|
+
return doc.toString(STYLE);
|
|
6749
|
+
}
|
|
6750
|
+
/** Deep equality over the plain values a block parses to. */
|
|
6751
|
+
function sameValue(a, b) {
|
|
6752
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
6753
|
+
}
|
|
6754
|
+
/**
|
|
6755
|
+
* A new block holding `data`, in the EtherPK style: `---\n…---\n`, or '' when `data` has no keys.
|
|
6756
|
+
* For a document that has no block yet; to change a block that exists use {@link editFrontmatter},
|
|
6757
|
+
* which keeps what the block already says.
|
|
6758
|
+
*/
|
|
6759
|
+
function renderFrontmatter(data) {
|
|
6760
|
+
if (Object.keys(data).length === 0) return "";
|
|
6761
|
+
return `---\n${styledYaml(new Document(data))}---\n`;
|
|
6762
|
+
}
|
|
6763
|
+
/** The line terminator a block uses, so a rewrite does not mix endings in a CRLF file. */
|
|
6764
|
+
function eolOf(text, end) {
|
|
6765
|
+
return text.slice(0, end).includes("\r\n") ? "\r\n" : "\n";
|
|
6766
|
+
}
|
|
6767
|
+
function withEol(yaml, eol) {
|
|
6768
|
+
return eol === "\n" ? yaml : yaml.replace(/\n/g, eol);
|
|
6769
|
+
}
|
|
6770
|
+
function editorFor(doc) {
|
|
6771
|
+
const map = doc.contents;
|
|
6772
|
+
const keyOf = (item) => String(isScalar(item.key) ? item.key.value : item.key);
|
|
6773
|
+
return {
|
|
6774
|
+
has: (key) => map.has(key),
|
|
6775
|
+
get: (key) => map.has(key) ? doc.toJS()[key] : void 0,
|
|
6776
|
+
set(key, value, where = "last") {
|
|
6777
|
+
if (map.has(key)) {
|
|
6778
|
+
const current = doc.toJS()[key];
|
|
6779
|
+
if (sameValue(current, value)) return;
|
|
6780
|
+
map.set(key, doc.createNode(value));
|
|
6781
|
+
return;
|
|
6782
|
+
}
|
|
6783
|
+
const pair = doc.createPair(key, value);
|
|
6784
|
+
if (where === "first") map.items.unshift(pair);
|
|
6785
|
+
else map.items.push(pair);
|
|
6786
|
+
},
|
|
6787
|
+
delete: (key) => {
|
|
6788
|
+
map.delete(key);
|
|
6789
|
+
},
|
|
6790
|
+
keys: () => map.items.map(keyOf)
|
|
6791
|
+
};
|
|
6792
|
+
}
|
|
6793
|
+
/**
|
|
6794
|
+
* The text with its block changed by `edit`, then written in the EtherPK style. The same string
|
|
6795
|
+
* comes back when the edit changes nothing the block says, so a writer that runs on every save
|
|
6796
|
+
* never restyles a block it agrees with. A block whose YAML does not parse is left alone: rewriting
|
|
6797
|
+
* it would destroy whatever the person was typing. A block the edit empties of every key is
|
|
6798
|
+
* removed.
|
|
6799
|
+
*/
|
|
6800
|
+
function editFrontmatter(text, edit, options = {}) {
|
|
6801
|
+
const span = frontmatterSpan(text);
|
|
6802
|
+
if (!span && !options.addBlock) return text;
|
|
6803
|
+
const doc = span ? parseBlock(span.body) : parseBlock("");
|
|
6804
|
+
if (doc === null) return text;
|
|
6805
|
+
const before = doc.toJS();
|
|
6806
|
+
edit(editorFor(doc));
|
|
6807
|
+
if (sameValue(doc.toJS(), before)) return text;
|
|
6808
|
+
const body = span ? text.slice(span.end) : text;
|
|
6809
|
+
if (doc.contents.items.length === 0) return body;
|
|
6810
|
+
const eol = span ? eolOf(text, span.end) : "\n";
|
|
6811
|
+
return `---${eol}${withEol(styledYaml(doc), eol)}---${eol}${body}`;
|
|
6812
|
+
}
|
|
6813
|
+
/**
|
|
6814
|
+
* Whether a value counts as not set (ADR 0108): nothing, a blank string, an empty list, or a
|
|
6815
|
+
* mapping whose every value is itself empty. `false` and `0` are values.
|
|
6816
|
+
*/
|
|
6817
|
+
function isEmptyValue(value) {
|
|
6818
|
+
if (value === null || value === void 0) return true;
|
|
6819
|
+
if (typeof value === "string") return value.trim() === "";
|
|
6820
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
6821
|
+
if (typeof value === "object" && !(value instanceof Date)) return Object.values(value).every(isEmptyValue);
|
|
6822
|
+
return false;
|
|
6823
|
+
}
|
|
6824
|
+
//#endregion
|
|
6399
6825
|
//#region ../client/src/lib/document/publish/publication.ts
|
|
6400
6826
|
/** A publication id: kebab-case, what `publications:` entries and `theme:` graph ids look like. */
|
|
6401
6827
|
var PUBLICATION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -6411,13 +6837,19 @@ function readMembership(text, concept) {
|
|
|
6411
6837
|
const { data } = parseFrontmatter(text);
|
|
6412
6838
|
const issues = [];
|
|
6413
6839
|
const isPublic = data.public === true;
|
|
6840
|
+
if (!isEmptyValue(data.public) && typeof data.public !== "boolean") issues.push({
|
|
6841
|
+
level: "warning",
|
|
6842
|
+
code: "public-not-a-boolean",
|
|
6843
|
+
message: `\`public\` is the text "${String(data.public)}", not \`true\` or \`false\`, so the document is not public. Write \`public: true\` without quotes to publish it.`,
|
|
6844
|
+
concept
|
|
6845
|
+
});
|
|
6414
6846
|
const publications = [];
|
|
6415
|
-
const raw = data.publications;
|
|
6416
|
-
const entries = raw === void 0 ? [] : Array.isArray(raw) ? raw :
|
|
6847
|
+
const raw = isEmptyValue(data.publications) ? void 0 : data.publications;
|
|
6848
|
+
const entries = raw === void 0 ? [] : Array.isArray(raw) ? raw : null;
|
|
6417
6849
|
if (entries === null) issues.push({
|
|
6418
6850
|
level: "warning",
|
|
6419
6851
|
code: "publications-not-a-list",
|
|
6420
|
-
message: "`publications` must be a list of publication ids,
|
|
6852
|
+
message: "`publications` must be a list of publication ids, each on its own line under the key as `- docs`, even when there is one.",
|
|
6421
6853
|
concept
|
|
6422
6854
|
});
|
|
6423
6855
|
else for (const entry of entries) if (isPublicationId(entry)) {
|
|
@@ -6434,13 +6866,33 @@ function readMembership(text, concept) {
|
|
|
6434
6866
|
issues
|
|
6435
6867
|
};
|
|
6436
6868
|
}
|
|
6869
|
+
var DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
6870
|
+
/**
|
|
6871
|
+
* The day a page is dated on a blog, from its `date:` key, and a warning when the key holds
|
|
6872
|
+
* something that is not a calendar day. A journal entry is dated by its name, never by this key.
|
|
6873
|
+
*/
|
|
6874
|
+
function readPageDate(doc) {
|
|
6875
|
+
const date = parseFrontmatter(doc.text).data.date;
|
|
6876
|
+
if (isEmptyValue(date)) return { issues: [] };
|
|
6877
|
+
const text = date instanceof Date ? date.toISOString().slice(0, 10) : String(date);
|
|
6878
|
+
if (DATE.test(text)) return {
|
|
6879
|
+
date: text,
|
|
6880
|
+
issues: []
|
|
6881
|
+
};
|
|
6882
|
+
return { issues: [{
|
|
6883
|
+
level: "warning",
|
|
6884
|
+
code: "invalid-date",
|
|
6885
|
+
message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD); the document is undated.`,
|
|
6886
|
+
concept: doc.concept
|
|
6887
|
+
}] };
|
|
6888
|
+
}
|
|
6437
6889
|
var KINDS$1 = ["docs", "blog"];
|
|
6438
6890
|
var SELECTIONS = ["named", "all-public"];
|
|
6439
6891
|
/** The publication a page defines through its `publication:` mapping, validated. */
|
|
6440
6892
|
function readPublicationDefinition(doc) {
|
|
6441
6893
|
const { data, body } = parseFrontmatter(doc.text);
|
|
6442
6894
|
const raw = data.publication;
|
|
6443
|
-
if (raw
|
|
6895
|
+
if (isEmptyValue(raw)) return {
|
|
6444
6896
|
publication: null,
|
|
6445
6897
|
issues: []
|
|
6446
6898
|
};
|
|
@@ -6465,7 +6917,7 @@ function readPublicationDefinition(doc) {
|
|
|
6465
6917
|
issues
|
|
6466
6918
|
};
|
|
6467
6919
|
}
|
|
6468
|
-
const def = raw;
|
|
6920
|
+
const def = Object.fromEntries(Object.entries(raw).filter(([, value]) => !isEmptyValue(value)));
|
|
6469
6921
|
const id = def.id;
|
|
6470
6922
|
if (id === void 0 || id === null || id === "") issue("publication-missing-id", "`publication` needs an `id`: lower-case letters, digits and hyphens, like `docs`.");
|
|
6471
6923
|
else if (!isPublicationId(id)) issue("publication-invalid-id", `"${String(id)}" is not a publication id: use lower-case letters, digits and hyphens, like \`docs\`.`);
|
|
@@ -6486,13 +6938,16 @@ function readPublicationDefinition(doc) {
|
|
|
6486
6938
|
if (def.recent !== void 0) if (typeof def.recent === "number" && Number.isInteger(def.recent) && def.recent > 0) recent = def.recent;
|
|
6487
6939
|
else issue("publication-invalid-recent", "`recent` must be a whole number above zero: how many posts the front page lists (10 unless set).");
|
|
6488
6940
|
const includes = {};
|
|
6489
|
-
if (def.includes !== void 0) if (typeof def.includes === "object" && def.includes !== null && !Array.isArray(def.includes)) for (const [slot, value] of Object.entries(def.includes))
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6941
|
+
if (def.includes !== void 0) if (typeof def.includes === "object" && def.includes !== null && !Array.isArray(def.includes)) for (const [slot, value] of Object.entries(def.includes)) {
|
|
6942
|
+
if (isEmptyValue(value)) continue;
|
|
6943
|
+
if (typeof value === "string" && value.trim() !== "") includes[slot] = value.trim();
|
|
6944
|
+
else issues.push({
|
|
6945
|
+
level: "warning",
|
|
6946
|
+
code: "publication-invalid-include",
|
|
6947
|
+
message: `Include \`${slot}\` must name the page that fills it; it is ignored.`,
|
|
6948
|
+
concept: doc.concept
|
|
6949
|
+
});
|
|
6950
|
+
}
|
|
6496
6951
|
else issue("publication-invalid-includes", "`includes` must be a mapping of include slot to the page that fills it.");
|
|
6497
6952
|
if (issues.some((i) => i.level === "error")) return {
|
|
6498
6953
|
publication: null,
|
|
@@ -8557,7 +9012,7 @@ function createIndexCore(host) {
|
|
|
8557
9012
|
}];
|
|
8558
9013
|
}
|
|
8559
9014
|
case "search-text": {
|
|
8560
|
-
const page = db ? searchText$1(db, request.query, request.offset, request.limit) : {
|
|
9015
|
+
const page = db ? searchText$1(db, request.query, request.offset, request.limit, request.filters ?? []) : {
|
|
8561
9016
|
groups: [],
|
|
8562
9017
|
hasMore: false
|
|
8563
9018
|
};
|
|
@@ -8569,7 +9024,7 @@ function createIndexCore(host) {
|
|
|
8569
9024
|
}];
|
|
8570
9025
|
}
|
|
8571
9026
|
case "search-count": {
|
|
8572
|
-
const count = db ? searchTextCount(db, request.query) : {
|
|
9027
|
+
const count = db ? searchTextCount(db, request.query, request.filters ?? []) : {
|
|
8573
9028
|
total: 0,
|
|
8574
9029
|
capped: false
|
|
8575
9030
|
};
|
|
@@ -8580,6 +9035,21 @@ function createIndexCore(host) {
|
|
|
8580
9035
|
capped: count.capped
|
|
8581
9036
|
}];
|
|
8582
9037
|
}
|
|
9038
|
+
case "property-keys": return [{
|
|
9039
|
+
type: "property-keys",
|
|
9040
|
+
id: request.id,
|
|
9041
|
+
keys: db ? propertyKeys(db) : []
|
|
9042
|
+
}];
|
|
9043
|
+
case "property-values": return [{
|
|
9044
|
+
type: "property-values",
|
|
9045
|
+
id: request.id,
|
|
9046
|
+
values: db ? propertyValues(db, request.key) : []
|
|
9047
|
+
}];
|
|
9048
|
+
case "property-match": return [{
|
|
9049
|
+
type: "property-match",
|
|
9050
|
+
id: request.id,
|
|
9051
|
+
documents: db ? documentsMatchingProperties(db, request.filters) : []
|
|
9052
|
+
}];
|
|
8583
9053
|
case "tasks": {
|
|
8584
9054
|
const page = db ? tasksMatching(db, request.query, request.offset, request.limit) : {
|
|
8585
9055
|
hits: [],
|
|
@@ -9297,30 +9767,52 @@ function createRemoteGraphIndex(source, transport, options) {
|
|
|
9297
9767
|
needles: [...needles]
|
|
9298
9768
|
}))).usage;
|
|
9299
9769
|
},
|
|
9300
|
-
async searchText(query, offset, limit) {
|
|
9770
|
+
async searchText(query, offset, limit, filters) {
|
|
9301
9771
|
const response = await request((id) => ({
|
|
9302
9772
|
type: "search-text",
|
|
9303
9773
|
id,
|
|
9304
9774
|
query,
|
|
9305
9775
|
offset,
|
|
9306
|
-
limit
|
|
9776
|
+
limit,
|
|
9777
|
+
...filters?.length ? { filters: [...filters] } : {}
|
|
9307
9778
|
}));
|
|
9308
9779
|
return {
|
|
9309
9780
|
groups: response.groups,
|
|
9310
9781
|
hasMore: response.hasMore
|
|
9311
9782
|
};
|
|
9312
9783
|
},
|
|
9313
|
-
async searchTextCount(query) {
|
|
9784
|
+
async searchTextCount(query, filters) {
|
|
9314
9785
|
const response = await request((id) => ({
|
|
9315
9786
|
type: "search-count",
|
|
9316
9787
|
id,
|
|
9317
|
-
query
|
|
9788
|
+
query,
|
|
9789
|
+
...filters?.length ? { filters: [...filters] } : {}
|
|
9318
9790
|
}));
|
|
9319
9791
|
return {
|
|
9320
9792
|
total: response.total,
|
|
9321
9793
|
capped: response.capped
|
|
9322
9794
|
};
|
|
9323
9795
|
},
|
|
9796
|
+
async propertyKeys() {
|
|
9797
|
+
return (await request((id) => ({
|
|
9798
|
+
type: "property-keys",
|
|
9799
|
+
id
|
|
9800
|
+
}))).keys;
|
|
9801
|
+
},
|
|
9802
|
+
async propertyValues(key) {
|
|
9803
|
+
return (await request((id) => ({
|
|
9804
|
+
type: "property-values",
|
|
9805
|
+
id,
|
|
9806
|
+
key
|
|
9807
|
+
}))).values;
|
|
9808
|
+
},
|
|
9809
|
+
async propertyMatch(filters) {
|
|
9810
|
+
return (await request((id) => ({
|
|
9811
|
+
type: "property-match",
|
|
9812
|
+
id,
|
|
9813
|
+
filters: [...filters]
|
|
9814
|
+
}))).documents;
|
|
9815
|
+
},
|
|
9324
9816
|
async tasks(query, offset, limit) {
|
|
9325
9817
|
const response = await request((id) => ({
|
|
9326
9818
|
type: "tasks",
|
|
@@ -10067,7 +10559,7 @@ async function deleteGraphTheme(adapter, id) {
|
|
|
10067
10559
|
/** What the block claims. An unterminated block is not a block, as everywhere else. */
|
|
10068
10560
|
function frontmatterIdentity(text) {
|
|
10069
10561
|
const span = frontmatterSpan(text);
|
|
10070
|
-
const data = span ?
|
|
10562
|
+
const data = span ? frontmatterData(span.body) : {};
|
|
10071
10563
|
if (data === null) return {
|
|
10072
10564
|
title: null,
|
|
10073
10565
|
aliases: [],
|
|
@@ -10119,37 +10611,19 @@ function sameAliases(a, b) {
|
|
|
10119
10611
|
*/
|
|
10120
10612
|
function withFrontmatterIdentity(text, patch, options = {}) {
|
|
10121
10613
|
const span = frontmatterSpan(text);
|
|
10122
|
-
if (
|
|
10123
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
const
|
|
10127
|
-
if (
|
|
10128
|
-
if (Object.keys(data).length === 0) return text;
|
|
10129
|
-
return `---\n${stringify(data)}---\n${text}`;
|
|
10130
|
-
}
|
|
10131
|
-
const data = parseBlock$2(span.body);
|
|
10132
|
-
if (data === null) return text;
|
|
10133
|
-
const currentTitle = titleOf(data);
|
|
10134
|
-
const currentAliases = aliasesOf({ data });
|
|
10135
|
-
const wantTitle = patch.title === void 0 ? currentTitle : patch.title;
|
|
10136
|
-
const wantAliases = patch.aliases === void 0 ? currentAliases : [...patch.aliases];
|
|
10137
|
-
if (wantTitle === currentTitle && sameAliases(wantAliases, currentAliases)) return text;
|
|
10138
|
-
const next = {};
|
|
10139
|
-
if (wantTitle !== null && !("title" in data)) next.title = wantTitle;
|
|
10140
|
-
for (const [key, value] of Object.entries(data)) {
|
|
10141
|
-
if (key === "title") {
|
|
10142
|
-
if (wantTitle !== null) next.title = wantTitle;
|
|
10143
|
-
continue;
|
|
10144
|
-
}
|
|
10145
|
-
if (key === "aliases") {
|
|
10146
|
-
if (wantAliases.length > 0) next.aliases = wantAliases;
|
|
10147
|
-
continue;
|
|
10148
|
-
}
|
|
10149
|
-
next[key] = value;
|
|
10614
|
+
if (span) {
|
|
10615
|
+
const data = frontmatterData(span.body);
|
|
10616
|
+
if (data === null) return text;
|
|
10617
|
+
const wantTitle = patch.title === void 0 ? titleOf(data) : patch.title;
|
|
10618
|
+
const wantAliases = patch.aliases === void 0 ? aliasesOf({ data }) : patch.aliases;
|
|
10619
|
+
if (wantTitle === titleOf(data) && sameAliases(wantAliases, aliasesOf({ data }))) return text;
|
|
10150
10620
|
}
|
|
10151
|
-
|
|
10152
|
-
|
|
10621
|
+
return editFrontmatter(text, (block) => {
|
|
10622
|
+
if (patch.title !== void 0) if (patch.title === null || patch.title.trim() === "") block.delete("title");
|
|
10623
|
+
else block.set("title", patch.title, "first");
|
|
10624
|
+
if (patch.aliases !== void 0) if (patch.aliases.length > 0) block.set("aliases", [...patch.aliases]);
|
|
10625
|
+
else block.delete("aliases");
|
|
10626
|
+
}, options);
|
|
10153
10627
|
}
|
|
10154
10628
|
/**
|
|
10155
10629
|
* `after` - a writer's rewrite of `before` - with the document's aliases carried into the block
|
|
@@ -10164,19 +10638,83 @@ function withAliasesInAddedBlock(before, after, aliases) {
|
|
|
10164
10638
|
if (aliases.length === 0 || frontmatterSpan(before) !== null || frontmatterSpan(after) === null) return after;
|
|
10165
10639
|
return withFrontmatterIdentity(after, { aliases });
|
|
10166
10640
|
}
|
|
10167
|
-
|
|
10168
|
-
|
|
10169
|
-
|
|
10170
|
-
|
|
10171
|
-
|
|
10172
|
-
|
|
10173
|
-
|
|
10174
|
-
|
|
10175
|
-
|
|
10641
|
+
//#endregion
|
|
10642
|
+
//#region ../client/src/lib/document/properties.ts
|
|
10643
|
+
/**
|
|
10644
|
+
* Keys a Property Filter answers from the graph's names rather than the block: the registry on
|
|
10645
|
+
* a Server Backend holds them, and a page created in the app there has no block to read.
|
|
10646
|
+
*/
|
|
10647
|
+
var NAME_PROPERTY_KEYS = new Set(["title", "aliases"]);
|
|
10648
|
+
/** Rows per document at most. Frontmatter is metadata; a block past this is not a label set. */
|
|
10649
|
+
var MAX_ROWS = 256;
|
|
10650
|
+
/** Characters of one value kept. Longer text is prose, which Search's text group covers. */
|
|
10651
|
+
var MAX_VALUE_LENGTH = 1e3;
|
|
10652
|
+
/**
|
|
10653
|
+
* Whether a value counts as not set (ADR 0108): null, blank text, a list with no set item, or a
|
|
10654
|
+
* mapping with no set value. `false` and `0` are values.
|
|
10655
|
+
*/
|
|
10656
|
+
function isEmptyPropertyValue(value) {
|
|
10657
|
+
if (value === null || value === void 0) return true;
|
|
10658
|
+
if (typeof value === "string") return value.trim() === "";
|
|
10659
|
+
if (Array.isArray(value)) return value.every(isEmptyPropertyValue);
|
|
10660
|
+
if (isMapping(value)) return Object.values(value).every(isEmptyPropertyValue);
|
|
10661
|
+
return false;
|
|
10662
|
+
}
|
|
10663
|
+
/**
|
|
10664
|
+
* The rows for one document's parsed Frontmatter, in the block's order. Empty values are left
|
|
10665
|
+
* out, and so are `title` and `aliases` in any spelling: those filters read the graph's names.
|
|
10666
|
+
*/
|
|
10667
|
+
function propertiesOf(data) {
|
|
10668
|
+
const out = [];
|
|
10669
|
+
for (const [key, value] of Object.entries(data)) {
|
|
10670
|
+
if (NAME_PROPERTY_KEYS.has(key.toLowerCase())) continue;
|
|
10671
|
+
collect(key, value, out);
|
|
10672
|
+
if (out.length >= MAX_ROWS) break;
|
|
10673
|
+
}
|
|
10674
|
+
return out.slice(0, MAX_ROWS);
|
|
10675
|
+
}
|
|
10676
|
+
/** Append the rows for `value` under `path`; a mapping adds its presence row before its values. */
|
|
10677
|
+
function collect(path, value, out) {
|
|
10678
|
+
if (isEmptyPropertyValue(value)) return;
|
|
10679
|
+
if (Array.isArray(value)) {
|
|
10680
|
+
let presence = false;
|
|
10681
|
+
for (const item of value) if (isMapping(item)) {
|
|
10682
|
+
if (!presence && !isEmptyPropertyValue(item)) {
|
|
10683
|
+
out.push({
|
|
10684
|
+
key: path,
|
|
10685
|
+
value: null
|
|
10686
|
+
});
|
|
10687
|
+
presence = true;
|
|
10688
|
+
}
|
|
10689
|
+
for (const [key, child] of Object.entries(item)) collect(`${path}.${key}`, child, out);
|
|
10690
|
+
} else collect(path, item, out);
|
|
10691
|
+
return;
|
|
10692
|
+
}
|
|
10693
|
+
if (isMapping(value)) {
|
|
10694
|
+
out.push({
|
|
10695
|
+
key: path,
|
|
10696
|
+
value: null
|
|
10697
|
+
});
|
|
10698
|
+
for (const [key, child] of Object.entries(value)) collect(`${path}.${key}`, child, out);
|
|
10699
|
+
return;
|
|
10176
10700
|
}
|
|
10701
|
+
out.push({
|
|
10702
|
+
key: path,
|
|
10703
|
+
value: textOf(value).slice(0, MAX_VALUE_LENGTH)
|
|
10704
|
+
});
|
|
10177
10705
|
}
|
|
10178
|
-
function
|
|
10179
|
-
return
|
|
10706
|
+
function isMapping(value) {
|
|
10707
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
|
|
10708
|
+
}
|
|
10709
|
+
/** A scalar as the text a person would type to find it. */
|
|
10710
|
+
function textOf(value) {
|
|
10711
|
+
if (value instanceof Date) {
|
|
10712
|
+
const iso = value.toISOString();
|
|
10713
|
+
return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
|
|
10714
|
+
}
|
|
10715
|
+
if (typeof value === "string") return value;
|
|
10716
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
10717
|
+
return JSON.stringify(value) ?? "";
|
|
10180
10718
|
}
|
|
10181
10719
|
//#endregion
|
|
10182
10720
|
//#region ../client/src/lib/document/calendar/month-grid-core.ts
|
|
@@ -10757,23 +11295,6 @@ async function scanGraph(adapter, options = {}) {
|
|
|
10757
11295
|
//#endregion
|
|
10758
11296
|
//#region ../client/src/lib/storage/fs/filesystem-store.ts
|
|
10759
11297
|
/**
|
|
10760
|
-
* A {@link DocumentStore} over a real directory (via a {@link DirectoryAdapter}):
|
|
10761
|
-
* the Filesystem Backend. The files *are* the state — this holds only a live
|
|
10762
|
-
* buffer per open document plus a derived registry, and reconciles external
|
|
10763
|
-
* changes (git checkout, Syncthing) the way DESIGN.md → The git workflow requires.
|
|
10764
|
-
*
|
|
10765
|
-
* Assembled entirely from the pure pieces (scan, identity, reconcileDecision,
|
|
10766
|
-
* debounce) so it is exercised in Node over createMemoryDirectoryAdapter before
|
|
10767
|
-
* any browser code exists.
|
|
10768
|
-
*
|
|
10769
|
-
* Async-seam note: the seam's
|
|
10770
|
-
* `getText()` is synchronous but disk reads are async, so `open()` returns a
|
|
10771
|
-
* handle whose buffer is empty on first open and is hydrated by an internal
|
|
10772
|
-
* awaited read that then notifies subscribers the *external* way — which is safe
|
|
10773
|
-
* because DocumentView registers its `subscribe` listener in the same synchronous
|
|
10774
|
-
* onMount tick as its `getText()` seed, before the read resolves.
|
|
10775
|
-
*/
|
|
10776
|
-
/**
|
|
10777
11298
|
* The snapshot for one document's full text: identity from the registry, aliases and include
|
|
10778
11299
|
* facts from the frontmatter, the body for the index. Both backends build theirs this way, so
|
|
10779
11300
|
* the index sees one shape whatever holds the document.
|
|
@@ -10786,12 +11307,14 @@ function indexSnapshotOf(identity, text) {
|
|
|
10786
11307
|
text,
|
|
10787
11308
|
aliases: []
|
|
10788
11309
|
}) : [];
|
|
11310
|
+
const properties = propertiesOf(fm.data);
|
|
10789
11311
|
return {
|
|
10790
11312
|
concept: identity.concept,
|
|
10791
11313
|
kind: identity.kind,
|
|
10792
11314
|
aliases: aliasesOf(fm),
|
|
10793
11315
|
text: fm.body,
|
|
10794
|
-
...includes.length > 0 ? { includes } : {}
|
|
11316
|
+
...includes.length > 0 ? { includes } : {},
|
|
11317
|
+
...properties.length > 0 ? { properties } : {}
|
|
10795
11318
|
};
|
|
10796
11319
|
}
|
|
10797
11320
|
function applyTextChange(text, change) {
|
|
@@ -10964,7 +11487,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10964
11487
|
const fm = parseFrontmatter(text);
|
|
10965
11488
|
let aliases = aliasesOf(fm);
|
|
10966
11489
|
let body = fm.body;
|
|
10967
|
-
let
|
|
11490
|
+
let blockSource = text;
|
|
10968
11491
|
if (strategy === "alias") {
|
|
10969
11492
|
if (!aliases.some((a) => conceptKey(a) === conceptKey(step.from))) aliases = [...aliases, step.from];
|
|
10970
11493
|
}
|
|
@@ -10983,15 +11506,17 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
10983
11506
|
});
|
|
10984
11507
|
body = merged.body;
|
|
10985
11508
|
aliases = merged.aliases;
|
|
10986
|
-
|
|
11509
|
+
blockSource = existing.text;
|
|
10987
11510
|
}
|
|
10988
11511
|
aliases = normaliseAliases(aliases, step.into);
|
|
10989
|
-
|
|
10990
|
-
|
|
10991
|
-
|
|
11512
|
+
const blockSpan = frontmatterSpan(blockSource);
|
|
11513
|
+
const content = withFrontmatterIdentity((blockSpan ? blockSource.slice(0, blockSpan.end) : "") + body, {
|
|
11514
|
+
title: step.into,
|
|
11515
|
+
aliases
|
|
11516
|
+
}, { addBlock: true });
|
|
10992
11517
|
const subdir = survivor?.subdir ?? entry.subdir;
|
|
10993
11518
|
const fileName = survivor?.fileName ?? await allocateFileName(subdir, step.into, entry);
|
|
10994
|
-
const written = await adapter.write(subdir, fileName,
|
|
11519
|
+
const written = await adapter.write(subdir, fileName, content);
|
|
10995
11520
|
if (!(subdir === entry.subdir && fileName === entry.fileName)) await adapter.remove(entry.subdir, entry.fileName);
|
|
10996
11521
|
registry.delete(entry.key);
|
|
10997
11522
|
if (survivor) registry.delete(survivor.key);
|
|
@@ -11354,7 +11879,7 @@ function createFilesystemDocumentStore(adapter, options = {}) {
|
|
|
11354
11879
|
if (registry.get(key)) throw new Error(`A document for "${concept}" already exists.`);
|
|
11355
11880
|
await adapter.ensureSkeleton();
|
|
11356
11881
|
const fileName = await allocateFileName("pages", concept);
|
|
11357
|
-
const content =
|
|
11882
|
+
const content = renderFrontmatter({ title: concept }) + body;
|
|
11358
11883
|
await adapter.write("pages", fileName, content);
|
|
11359
11884
|
await refreshRegistry();
|
|
11360
11885
|
return concept;
|
|
@@ -11847,12 +12372,14 @@ function createServerDocumentStore(graph, options) {
|
|
|
11847
12372
|
text,
|
|
11848
12373
|
aliases: []
|
|
11849
12374
|
}) : [];
|
|
12375
|
+
const properties = propertiesOf(parseFrontmatter(text).data);
|
|
11850
12376
|
return {
|
|
11851
12377
|
concept,
|
|
11852
12378
|
kind: entry.kind,
|
|
11853
12379
|
aliases: entry.aliases ?? [],
|
|
11854
12380
|
text: bodyForIndex(text),
|
|
11855
|
-
...includes.length > 0 ? { includes } : {}
|
|
12381
|
+
...includes.length > 0 ? { includes } : {},
|
|
12382
|
+
...properties.length > 0 ? { properties } : {}
|
|
11856
12383
|
};
|
|
11857
12384
|
}
|
|
11858
12385
|
/**
|
|
@@ -13005,8 +13532,9 @@ function assembleHeadlessGraph(parts) {
|
|
|
13005
13532
|
}
|
|
13006
13533
|
/** Open a synced graph, scan its registry and build the index; resolves once tools can answer. */
|
|
13007
13534
|
async function openHeadlessGraph(deps) {
|
|
13008
|
-
const
|
|
13009
|
-
|
|
13535
|
+
const cacheName = deps.localCacheName ?? deps.graphId;
|
|
13536
|
+
const cache = await openGraphCache(cacheName);
|
|
13537
|
+
if (deps.persistDir) await loadLocalCache(deps.persistDir, cacheName);
|
|
13010
13538
|
const indexHost = deps.persistDir ? nodeIndexHost(deps.persistDir) : void 0;
|
|
13011
13539
|
const sync = createGraphSync({
|
|
13012
13540
|
graphId: deps.graphId,
|
|
@@ -13135,7 +13663,7 @@ async function openHeadlessGraph(deps) {
|
|
|
13135
13663
|
embeddingModel: deps.embeddingModel,
|
|
13136
13664
|
onSemanticProgress: deps.onSemanticProgress,
|
|
13137
13665
|
onError: deps.onError,
|
|
13138
|
-
persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir,
|
|
13666
|
+
persistBackend: deps.persistDir ? () => persistLocalCache(deps.persistDir, cacheName) : void 0,
|
|
13139
13667
|
onChange: (schedule) => sync.onDocUpdate(schedule),
|
|
13140
13668
|
async settle(schedulePersist) {
|
|
13141
13669
|
await sync.flushAll();
|
|
@@ -13989,134 +14517,43 @@ function bundledThemeList() {
|
|
|
13989
14517
|
//#region ../client/src/lib/document/frontmatter/patch.ts
|
|
13990
14518
|
/**
|
|
13991
14519
|
* Rewriting arbitrary keys of a document's [[Frontmatter]] block, the way `withFrontmatterIdentity`
|
|
13992
|
-
* rewrites identity: other keys keep their values and
|
|
13993
|
-
* nothing would change, and a block whose YAML does not parse is left alone rather than
|
|
14520
|
+
* rewrites identity: other keys keep their values, order and comments, the same string comes back
|
|
14521
|
+
* when nothing would change, and a block whose YAML does not parse is left alone rather than
|
|
13994
14522
|
* destroyed. `null` removes a key. A block emptied of every key is removed with it. Identity keys
|
|
13995
14523
|
* are not this function's business: pass them through `withFrontmatterIdentity` so the registry
|
|
13996
14524
|
* rules (ADR 0061) apply.
|
|
13997
14525
|
*/
|
|
13998
|
-
function parseBlock$1(yaml) {
|
|
13999
|
-
try {
|
|
14000
|
-
const parsed = parse(yaml);
|
|
14001
|
-
if (parsed === null || parsed === void 0) return {};
|
|
14002
|
-
if (typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
14003
|
-
return parsed;
|
|
14004
|
-
} catch {
|
|
14005
|
-
return null;
|
|
14006
|
-
}
|
|
14007
|
-
}
|
|
14008
|
-
function same(a, b) {
|
|
14009
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
14010
|
-
}
|
|
14011
14526
|
function withFrontmatterPatch(text, patch, options = {}) {
|
|
14012
|
-
|
|
14013
|
-
|
|
14014
|
-
|
|
14015
|
-
|
|
14016
|
-
for (const [key, value] of Object.entries(patch)) if (value !== null && value !== void 0) data[key] = value;
|
|
14017
|
-
if (Object.keys(data).length === 0) return text;
|
|
14018
|
-
return `---\n${stringify(data)}---\n${text}`;
|
|
14019
|
-
}
|
|
14020
|
-
const data = parseBlock$1(span.body);
|
|
14021
|
-
if (data === null) return text;
|
|
14022
|
-
const next = {};
|
|
14023
|
-
let changed = false;
|
|
14024
|
-
for (const [key, value] of Object.entries(data)) {
|
|
14025
|
-
if (key in patch) {
|
|
14026
|
-
const wanted = patch[key];
|
|
14027
|
-
if (wanted === null || wanted === void 0) {
|
|
14028
|
-
changed = true;
|
|
14029
|
-
continue;
|
|
14030
|
-
}
|
|
14031
|
-
next[key] = wanted;
|
|
14032
|
-
if (!same(wanted, value)) changed = true;
|
|
14033
|
-
continue;
|
|
14034
|
-
}
|
|
14035
|
-
next[key] = value;
|
|
14036
|
-
}
|
|
14037
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
14038
|
-
if (key in data || value === null || value === void 0) continue;
|
|
14039
|
-
next[key] = value;
|
|
14040
|
-
changed = true;
|
|
14041
|
-
}
|
|
14042
|
-
if (!changed) return text;
|
|
14043
|
-
if (Object.keys(next).length === 0) return text.slice(span.end);
|
|
14044
|
-
return `---\n${stringify(next)}---\n${text.slice(span.end)}`;
|
|
14527
|
+
return editFrontmatter(text, (block) => {
|
|
14528
|
+
for (const [key, value] of Object.entries(patch)) if (value === null || value === void 0) block.delete(key);
|
|
14529
|
+
else block.set(key, value);
|
|
14530
|
+
}, options);
|
|
14045
14531
|
}
|
|
14046
14532
|
//#endregion
|
|
14047
14533
|
//#region ../client/src/lib/document/frontmatter/publishing.ts
|
|
14048
14534
|
/**
|
|
14049
14535
|
* The publishing keys of a document's [[Frontmatter]] (ADR 0082): `public: true` is the consent
|
|
14050
|
-
* switch, `publications
|
|
14051
|
-
* key
|
|
14052
|
-
* key is that prompt to whoever edits the file by hand.
|
|
14053
|
-
*
|
|
14054
|
-
*
|
|
14055
|
-
*
|
|
14056
|
-
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
14060
|
-
if (parsed === null || parsed === void 0) return {};
|
|
14061
|
-
if (typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
14062
|
-
return parsed;
|
|
14063
|
-
} catch {
|
|
14064
|
-
return null;
|
|
14065
|
-
}
|
|
14066
|
-
}
|
|
14067
|
-
function sameList(a, b) {
|
|
14068
|
-
return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
|
|
14069
|
-
}
|
|
14536
|
+
* switch, and `publications`, a list of publication ids, routes. A public document with no
|
|
14537
|
+
* publication keeps the key, written bare (`publications:`): public with nowhere to go is a state
|
|
14538
|
+
* worth a prompt, and the empty key is that prompt to whoever edits the file by hand. Bare rather
|
|
14539
|
+
* than `[]` because that is how the EtherPK style writes every empty value (ADR 0108), and because
|
|
14540
|
+
* Enter after a bare key opens the next line one level in, ready for the first `- id`. Not public, the empty key is
|
|
14541
|
+
* removed. Rewritten the way `withFrontmatterIdentity` rewrites identity: other keys keep their
|
|
14542
|
+
* values and order, the same string comes back when nothing would change, and a block whose YAML
|
|
14543
|
+
* does not parse is left alone rather than destroyed. Read through `readMembership` in
|
|
14544
|
+
* `publish/publication.ts`, which reads a bare key and `[]` alike; this file only writes.
|
|
14545
|
+
*/
|
|
14070
14546
|
/** The text with its publishing keys rewritten. `addBlock` adds a block to a document without one. */
|
|
14071
14547
|
function withPublishing(text, patch, options = {}) {
|
|
14072
14548
|
const publications = patch.publications === void 0 || patch.publications === null ? patch.publications : [...new Set(patch.publications.filter(isPublicationId))];
|
|
14073
|
-
|
|
14074
|
-
|
|
14075
|
-
if (
|
|
14076
|
-
|
|
14077
|
-
|
|
14078
|
-
if (publications
|
|
14079
|
-
|
|
14080
|
-
|
|
14081
|
-
}
|
|
14082
|
-
const data = parseBlock(span.body);
|
|
14083
|
-
if (data === null) return text;
|
|
14084
|
-
const next = {};
|
|
14085
|
-
let changed = false;
|
|
14086
|
-
const wantPublic = patch.public === void 0 ? data.public : patch.public;
|
|
14087
|
-
const keepEmpty = wantPublic === true;
|
|
14088
|
-
const wantPublications = publications === void 0 ? data.publications : publications && (publications.length > 0 || keepEmpty) ? publications : null;
|
|
14089
|
-
for (const [key, value] of Object.entries(data)) {
|
|
14090
|
-
if (key === "public") {
|
|
14091
|
-
if (wantPublic === null || wantPublic === void 0) changed = true;
|
|
14092
|
-
else {
|
|
14093
|
-
next.public = wantPublic;
|
|
14094
|
-
if (wantPublic !== value) changed = true;
|
|
14095
|
-
}
|
|
14096
|
-
continue;
|
|
14097
|
-
}
|
|
14098
|
-
if (key === "publications") {
|
|
14099
|
-
if (wantPublications === null || wantPublications === void 0) changed = true;
|
|
14100
|
-
else {
|
|
14101
|
-
next.publications = wantPublications;
|
|
14102
|
-
if (!sameList(value, wantPublications)) changed = true;
|
|
14103
|
-
}
|
|
14104
|
-
continue;
|
|
14105
|
-
}
|
|
14106
|
-
next[key] = value;
|
|
14107
|
-
}
|
|
14108
|
-
if (!("public" in data) && (wantPublic === true || wantPublic === false)) {
|
|
14109
|
-
next.public = wantPublic;
|
|
14110
|
-
changed = true;
|
|
14111
|
-
}
|
|
14112
|
-
if (!("publications" in data) && Array.isArray(wantPublications) && (wantPublications.length > 0 || keepEmpty)) {
|
|
14113
|
-
next.publications = wantPublications;
|
|
14114
|
-
changed = true;
|
|
14115
|
-
}
|
|
14116
|
-
if (!changed) return text;
|
|
14117
|
-
const yaml = Object.keys(next).length === 0 ? "" : stringify(next);
|
|
14118
|
-
if (yaml === "") return text.slice(span.end);
|
|
14119
|
-
return `---\n${yaml}---\n${text.slice(span.end)}`;
|
|
14549
|
+
return editFrontmatter(text, (block) => {
|
|
14550
|
+
if (patch.public === null) block.delete("public");
|
|
14551
|
+
else if (patch.public !== void 0) block.set("public", patch.public);
|
|
14552
|
+
if (publications === void 0) return;
|
|
14553
|
+
const keepEmpty = (patch.public === void 0 ? block.get("public") : patch.public) === true;
|
|
14554
|
+
if (publications === null || publications.length === 0 && !keepEmpty) block.delete("publications");
|
|
14555
|
+
else block.set("publications", publications.length === 0 ? null : publications);
|
|
14556
|
+
}, options);
|
|
14120
14557
|
}
|
|
14121
14558
|
//#endregion
|
|
14122
14559
|
//#region ../client/src/lib/document/publish/seeded.ts
|
|
@@ -15211,7 +15648,7 @@ function allocate(documents) {
|
|
|
15211
15648
|
const derived = [];
|
|
15212
15649
|
for (const doc of documents) {
|
|
15213
15650
|
const value = parseFrontmatter(doc.text).data.slug;
|
|
15214
|
-
if (value
|
|
15651
|
+
if (isEmptyValue(value)) {
|
|
15215
15652
|
derived.push(doc);
|
|
15216
15653
|
continue;
|
|
15217
15654
|
}
|
|
@@ -15298,7 +15735,6 @@ function createThemeRenderer(theme, includes) {
|
|
|
15298
15735
|
* a pre-render pass for the asynchronous bits (diagrams, code); then rendering, includes, the
|
|
15299
15736
|
* theme, the derived files. Everything derived is computed from the included documents alone.
|
|
15300
15737
|
*/
|
|
15301
|
-
var DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
15302
15738
|
function assetHrefOf(name) {
|
|
15303
15739
|
return `assets/${encodeURIComponent(name)}`;
|
|
15304
15740
|
}
|
|
@@ -15542,17 +15978,9 @@ async function publishPublication(source, publication, env, options = {}) {
|
|
|
15542
15978
|
};
|
|
15543
15979
|
if (doc.kind === "journal") page.date = doc.concept;
|
|
15544
15980
|
else {
|
|
15545
|
-
const
|
|
15546
|
-
if (date !== void 0)
|
|
15547
|
-
|
|
15548
|
-
if (DATE.test(text)) page.date = text;
|
|
15549
|
-
else warnings.push({
|
|
15550
|
-
level: "warning",
|
|
15551
|
-
code: "invalid-date",
|
|
15552
|
-
message: `"${doc.concept}" has \`date: ${String(date)}\`, which is not a calendar day (YYYY-MM-DD); the document is undated.`,
|
|
15553
|
-
concept: doc.concept
|
|
15554
|
-
});
|
|
15555
|
-
}
|
|
15981
|
+
const dated = readPageDate(doc);
|
|
15982
|
+
if (dated.date !== void 0) page.date = dated.date;
|
|
15983
|
+
take(dated.issues);
|
|
15556
15984
|
}
|
|
15557
15985
|
for (const link of linksOf(bodyOf(doc), resolver, doc)) report.missingLinks.push(link);
|
|
15558
15986
|
pages.push(page);
|
|
@@ -16765,7 +17193,7 @@ function agentFrontmatter(rawText) {
|
|
|
16765
17193
|
/**
|
|
16766
17194
|
* The block after a patch. `public` and `publications` go through the publishing writer, so its
|
|
16767
17195
|
* rules hold whoever writes them: an id is lower-case letters, digits and hyphens, and a public
|
|
16768
|
-
* document with an empty list keeps
|
|
17196
|
+
* document with an empty list keeps the key, written bare, as the prompt it is. Everything else is a
|
|
16769
17197
|
* plain key. An invalid publication id is refused rather than dropped, which is what the
|
|
16770
17198
|
* writer would do: an agent that misspells an id must hear about it.
|
|
16771
17199
|
*/
|
|
@@ -16948,7 +17376,24 @@ function round(similarity) {
|
|
|
16948
17376
|
return Math.round(similarity * 1e3) / 1e3;
|
|
16949
17377
|
}
|
|
16950
17378
|
async function searchText(graph, query, offset, limit) {
|
|
16951
|
-
const
|
|
17379
|
+
const keys = await graph.index.propertyKeys();
|
|
17380
|
+
const { words, filters } = parseSearchQuery(query, new Set(keys.map((info) => info.key.toLowerCase())));
|
|
17381
|
+
if (filters.length > 0 && words.trim() === "") {
|
|
17382
|
+
const documents = await graph.index.propertyMatch(filters);
|
|
17383
|
+
return {
|
|
17384
|
+
results: documents.slice(offset, offset + limit).map((doc) => ({
|
|
17385
|
+
concept: doc.concept,
|
|
17386
|
+
kind: doc.kind,
|
|
17387
|
+
matches: 0,
|
|
17388
|
+
hits: [],
|
|
17389
|
+
properties: doc.properties
|
|
17390
|
+
})),
|
|
17391
|
+
total: documents.length,
|
|
17392
|
+
totalCapped: false,
|
|
17393
|
+
hasMore: offset + limit < documents.length
|
|
17394
|
+
};
|
|
17395
|
+
}
|
|
17396
|
+
const [result, count] = await Promise.all([graph.index.searchText(words, offset, limit, filters), graph.index.searchTextCount(words, filters)]);
|
|
16952
17397
|
return {
|
|
16953
17398
|
results: result.groups.map((group) => ({
|
|
16954
17399
|
concept: group.concept,
|
|
@@ -18518,7 +18963,7 @@ function createMcpServer(graph, info) {
|
|
|
18518
18963
|
}, async (args) => run(() => readDocuments(graph, args)));
|
|
18519
18964
|
server.registerTool("search", {
|
|
18520
18965
|
title: "Search",
|
|
18521
|
-
description: `Search every unprotected document. mode "text" (default): words match by prefix, "quoted phrases" match exactly; documents come most-matches-first with the matching lines. mode "semantic": the passages closest in MEANING to the query, best first, each with its text, a similarity 0..1 and where it is (concept, breadcrumb of headings and parent bullets, start and end line) - finds a note that says "self-assessment is due 31 January" for "tax deadline". Line numbers are 0-based in every mode. Passage text is the note's own words with bullet markers and [[ ]] stripped: quote and cite it, but anchor edit_document on text from read_document. Use semantic for questions and descriptions, text for names, identifiers and exact phrases; mode "hybrid" returns both as separate groups. Semantic results carry complete: false while this computer is still embedding the graph (rerun later for more). If semantic mode answers error semantic_unavailable, it is not set up on this computer: tell the user the command in the message. At most 50 documents per call.`,
|
|
18966
|
+
description: `Search every unprotected document. mode "text" (default): words match by prefix, "quoted phrases" match exactly; documents come most-matches-first with the matching lines. In text mode a term key:value is a property filter on the documents' frontmatter when some document has that key (keys and values ignore case; key:"two words", -key:value excludes, key:* means the key is set, key:va* matches the start of a value, publication.id:docs reaches a nested key, title: and aliases: match the document's names); filters alone list every matching document with the values they matched and no hits. mode "semantic": the passages closest in MEANING to the query, best first, each with its text, a similarity 0..1 and where it is (concept, breadcrumb of headings and parent bullets, start and end line) - finds a note that says "self-assessment is due 31 January" for "tax deadline". Line numbers are 0-based in every mode. Passage text is the note's own words with bullet markers and [[ ]] stripped: quote and cite it, but anchor edit_document on text from read_document. Use semantic for questions and descriptions, text for names, identifiers and exact phrases; mode "hybrid" returns both as separate groups. Semantic results carry complete: false while this computer is still embedding the graph (rerun later for more). If semantic mode answers error semantic_unavailable, it is not set up on this computer: tell the user the command in the message. At most 50 documents per call.`,
|
|
18522
18967
|
inputSchema: {
|
|
18523
18968
|
query: z.string().min(1),
|
|
18524
18969
|
mode: z.enum([
|