canon-atlas 0.1.0 → 0.2.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 +4 -0
- package/package.json +1 -1
- package/src/config.mjs +22 -6
- package/src/serve.mjs +24 -3
- package/src/ui/app.js +13 -0
- package/src/ui/pipeline.cjs +153 -6
- package/src/ui/shell.js +63 -8
- package/src/ui/style.css +4 -1
package/README.md
CHANGED
|
@@ -60,6 +60,10 @@ To define your own, put a `canon-atlas.json` at the root:
|
|
|
60
60
|
|
|
61
61
|
References that resolve nowhere are not errors: they render as dashed, unwritten nodes, because a name the corpus reaches for but has not written yet is a fact worth seeing. In live mode an unwritten node offers to be written.
|
|
62
62
|
|
|
63
|
+
### The article schema
|
|
64
|
+
|
|
65
|
+
A store may carry a `schema.json` beside its collections ([pi-canon](https://github.com/shaneconner/pi-canon) writes one when it creates a store), declaring rules for an article's `capsule`, `title`, and `body`: `required`, `min_chars`, `max_chars`, and a `hint`. The atlas enforces the same contract at every one of its write doors. Enforcement is asymmetric on purpose: a `required` rule rejects a save that touches the field (and everything on create), with nothing written and the editor keeping your text while it tells you what to correct; every other rule warns, and a document already in violation shows heal notes in the reader instead of errors, so a body edit is never held hostage to a legacy omission. A malformed `schema.json` fails open and loud: nothing is enforced, and the map panel and every save say so, because a contract the owner believes is enforced while a typo disabled it is the worst state.
|
|
66
|
+
|
|
63
67
|
## Color
|
|
64
68
|
|
|
65
69
|
Color is the cluster a document belongs to, sampled from one perceptual ramp (Batlow), and the force layout pulls each cluster into its own region, so a band of the palette and a region of the map mean the same thing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canon-atlas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "An atlas over a catalog of markdown documents that reference each other: a constellation graph, search, a reader, and local editing. Agnostic to what wrote the catalog.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.mjs
CHANGED
|
@@ -22,15 +22,31 @@ function isDir(p) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/* Resolution order: an explicit canon-atlas.json at the root wins, then preset
|
|
25
|
-
detection (a store with both articles/ and journal
|
|
26
|
-
bare default of one mutable collection
|
|
27
|
-
that names a title but no collections
|
|
25
|
+
detection (a store with both articles/ and journal/, at the root or nested
|
|
26
|
+
under .canon/, is pi-canon), then the bare default of one mutable collection
|
|
27
|
+
over every markdown file. A config that names a title but no collections
|
|
28
|
+
keeps the detected preset. The prefix handling lives in the shared
|
|
29
|
+
detectPreset so Node and the browser resolve the same store the same way. */
|
|
28
30
|
export function loadConfig(root) {
|
|
29
31
|
const file = join(root, CONFIG_NAME);
|
|
30
32
|
const raw = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
|
|
31
|
-
const detect = () =>
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const detect = () => {
|
|
34
|
+
for (const prefix of ["", ".canon/"]) {
|
|
35
|
+
if (isDir(join(root, prefix, "articles")) && isDir(join(root, prefix, "journal"))) {
|
|
36
|
+
return P.detectPreset([prefix + "articles/x.md", prefix + "journal/x.md"]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return P.DEFAULT_CONFIG;
|
|
40
|
+
};
|
|
41
|
+
const config = P.buildConfig(P.composeConfig(raw, detect));
|
|
42
|
+
/* The store's article contract, when it carries one: schema.json beside the
|
|
43
|
+
collections (pi-canon writes it at store creation). Parsed by the shared
|
|
44
|
+
pipeline so the browser and this process enforce identical rules. */
|
|
45
|
+
const sf = join(root, P.schemaPrefix(config), P.SCHEMA_FILE);
|
|
46
|
+
const s = P.parseSchema(existsSync(sf) ? readFileSync(sf, "utf8") : null);
|
|
47
|
+
config.schema = s.schema;
|
|
48
|
+
config.schemaProblems = s.problems;
|
|
49
|
+
return config;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
/* Optional embedding vectors at the path config.embeddings names. Absent is
|
package/src/serve.mjs
CHANGED
|
@@ -26,11 +26,14 @@ import {
|
|
|
26
26
|
} from "node:fs";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
29
|
+
import { createRequire } from "node:module";
|
|
29
30
|
import { loadConfig, loadVectors, collectionFor } from "./config.mjs";
|
|
30
31
|
import { scanCorpus } from "./scan.mjs";
|
|
31
32
|
import { buildGraph } from "./graph.mjs";
|
|
32
33
|
import { buildData, renderPage, renderAppPage } from "./page.mjs";
|
|
33
34
|
|
|
35
|
+
const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
|
|
36
|
+
|
|
34
37
|
const CACHE_TTL_MS = 3000;
|
|
35
38
|
const BODY_LIMIT = 4_000_000;
|
|
36
39
|
|
|
@@ -149,6 +152,22 @@ export function corpusApi(rootArg) {
|
|
|
149
152
|
return { full, rel: norm, col };
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
/* The store's schema, enforced at this door the way pi-canon enforces it at
|
|
156
|
+
its own: a required violation the save touched (or a create) rejects with
|
|
157
|
+
nothing written, so the client keeps the text and says what to correct;
|
|
158
|
+
everything else comes back as warnings beside the success. */
|
|
159
|
+
function schemaGate(content, prevRaw, col) {
|
|
160
|
+
const cfg = config();
|
|
161
|
+
if (col.immutable) return [];
|
|
162
|
+
/* A broken declaration enforces nothing, but every write says so. */
|
|
163
|
+
if (!cfg.schema) return cfg.schemaProblems || [];
|
|
164
|
+
const r = P.checkDoc(String(content ?? ""), prevRaw, cfg.schema, col.fields.summary);
|
|
165
|
+
if (r.rejects.length) {
|
|
166
|
+
throw httpError(422, "Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
|
|
167
|
+
}
|
|
168
|
+
return r.warns.concat(cfg.schemaProblems || []);
|
|
169
|
+
}
|
|
170
|
+
|
|
152
171
|
/* Serve one API request whose path inside the API is sub ("/graph",
|
|
153
172
|
"/doc"). Returns true when the route was taken; errors throw httpError
|
|
154
173
|
for the caller's catch, so both hosts report them the same way. */
|
|
@@ -169,12 +188,13 @@ export function corpusApi(rootArg) {
|
|
|
169
188
|
if (req.method === "POST") {
|
|
170
189
|
guardMutation(req);
|
|
171
190
|
const body = await readBody(req);
|
|
172
|
-
const { full, rel } = confine(body.path);
|
|
191
|
+
const { full, rel, col } = confine(body.path);
|
|
173
192
|
if (existsSync(full)) throw httpError(409, "document already exists");
|
|
193
|
+
const warnings = schemaGate(body.content, null, col);
|
|
174
194
|
mkdirSync(dirname(full), { recursive: true });
|
|
175
195
|
writeFileSync(full, String(body.content ?? ""), { flag: "wx" });
|
|
176
196
|
bust();
|
|
177
|
-
send(201, { path: rel });
|
|
197
|
+
send(201, warnings.length ? { path: rel, warnings } : { path: rel });
|
|
178
198
|
return true;
|
|
179
199
|
}
|
|
180
200
|
if (req.method === "PUT") {
|
|
@@ -183,9 +203,10 @@ export function corpusApi(rootArg) {
|
|
|
183
203
|
const { full, rel, col } = confine(body.path);
|
|
184
204
|
if (col.immutable) throw httpError(403, `${col.name} is immutable`);
|
|
185
205
|
if (!existsSync(full)) throw httpError(404, "no such document");
|
|
206
|
+
const warnings = schemaGate(body.content, readFileSync(full, "utf8"), col);
|
|
186
207
|
atomicWrite(full, String(body.content ?? ""));
|
|
187
208
|
bust();
|
|
188
|
-
send(200, { path: rel });
|
|
209
|
+
send(200, warnings.length ? { path: rel, warnings } : { path: rel });
|
|
189
210
|
return true;
|
|
190
211
|
}
|
|
191
212
|
if (req.method === "DELETE") {
|
package/src/ui/app.js
CHANGED
|
@@ -554,6 +554,11 @@
|
|
|
554
554
|
none: "No clusters yet: add links between documents, or provide embeddings, and regions will form.",
|
|
555
555
|
}[data.basis];
|
|
556
556
|
ov.appendChild(el("p", "side-meta", basisLine + " Select a cluster to isolate it, or any node to read it."));
|
|
557
|
+
/* A schema the owner believes is enforced while a typo disabled it is
|
|
558
|
+
the worst state, so declaration problems surface here, loud. */
|
|
559
|
+
if (data.schemaProblems && data.schemaProblems.length) {
|
|
560
|
+
ov.appendChild(el("p", "err", data.schemaProblems.join("\n")));
|
|
561
|
+
}
|
|
557
562
|
data.collections.forEach(function (c) {
|
|
558
563
|
if (state.mode[c.name] === "off" && c.count) {
|
|
559
564
|
ov.appendChild(el("p", "side-meta",
|
|
@@ -779,6 +784,11 @@
|
|
|
779
784
|
}
|
|
780
785
|
var kind = n.exists ? n.collection + (n.immutable ? ", immutable" : "") : "unwritten reference";
|
|
781
786
|
side.appendChild(el("p", "side-kind", kind));
|
|
787
|
+
/* A document in violation of the store's schema reads fine; the notes
|
|
788
|
+
say what an edit would heal. */
|
|
789
|
+
if (n.schemaNotes && n.schemaNotes.length) {
|
|
790
|
+
side.appendChild(el("p", "schema-note", n.schemaNotes.join("\n")));
|
|
791
|
+
}
|
|
782
792
|
var t = el("p", "side-title", n.title);
|
|
783
793
|
if (n.exists) t.style.color = d3.hcl(d3.color(n.color)).brighter(1.1) + "";
|
|
784
794
|
side.appendChild(t);
|
|
@@ -862,6 +872,9 @@
|
|
|
862
872
|
}
|
|
863
873
|
|
|
864
874
|
function showErr(err) {
|
|
875
|
+
/* Replace any standing error: a corrected retry must not stack lines. */
|
|
876
|
+
var old = side.querySelector(".err");
|
|
877
|
+
if (old) old.parentNode.removeChild(old);
|
|
865
878
|
var p = el("p", "err", String((err && err.message) || err));
|
|
866
879
|
side.insertBefore(p, side.firstChild);
|
|
867
880
|
}
|
package/src/ui/pipeline.cjs
CHANGED
|
@@ -96,13 +96,22 @@ var AtlasPipeline = (function () {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function detectPreset(relPaths) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
for (const prefix of ["", ".canon/"]) {
|
|
100
|
+
let hasArticles = false;
|
|
101
|
+
let hasJournal = false;
|
|
102
|
+
for (const p of relPaths) {
|
|
103
|
+
if (p.startsWith(prefix + "articles/")) hasArticles = true;
|
|
104
|
+
if (p.startsWith(prefix + "journal/")) hasJournal = true;
|
|
105
|
+
}
|
|
106
|
+
if (hasArticles && hasJournal) {
|
|
107
|
+
if (!prefix) return PI_CANON_PRESET;
|
|
108
|
+
return Object.assign({}, PI_CANON_PRESET, {
|
|
109
|
+
collections: PI_CANON_PRESET.collections.map((c) =>
|
|
110
|
+
Object.assign({}, c, { match: prefix + c.match })),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
104
113
|
}
|
|
105
|
-
return
|
|
114
|
+
return DEFAULT_CONFIG;
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/* Optional embedding vectors: a parsed JSON object of root-relative markdown
|
|
@@ -224,6 +233,124 @@ var AtlasPipeline = (function () {
|
|
|
224
233
|
return m ? m[1].trim() : "";
|
|
225
234
|
}
|
|
226
235
|
|
|
236
|
+
/* ── article schema ────────────────────────────────────────────────────────
|
|
237
|
+
A store may carry a schema.json beside its collections: pi-canon writes one
|
|
238
|
+
when it creates a store, and this file is the shared contract, so the atlas
|
|
239
|
+
enforces the same rules at its own write doors. The shape mirrors pi-canon
|
|
240
|
+
exactly: an "article" object with capsule / title / body rules, each rule
|
|
241
|
+
taking required, min_chars, max_chars, hint. Enforcement is asymmetric on
|
|
242
|
+
purpose: required rejects a save that touches the field (and everything on
|
|
243
|
+
create), everything else warns, and a document read in violation carries
|
|
244
|
+
heal notes instead of errors. A malformed file fails open and loud: nothing
|
|
245
|
+
is enforced and the problems say so, because a contract the owner believes
|
|
246
|
+
is enforced while a typo disabled it is the worst state. */
|
|
247
|
+
|
|
248
|
+
const SCHEMA_FILE = "schema.json";
|
|
249
|
+
const SCHEMA_FIELDS = ["capsule", "title", "body"];
|
|
250
|
+
const SCHEMA_RULE_KEYS = ["required", "min_chars", "max_chars", "hint"];
|
|
251
|
+
|
|
252
|
+
/* The store lives either at the corpus root or nested under .canon/; the
|
|
253
|
+
schema file sits beside the collections, wherever they are. */
|
|
254
|
+
function schemaPrefix(config) {
|
|
255
|
+
return config.collections.some((c) => (c.match || "").startsWith(".canon/")) ? ".canon/" : "";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* Raw file text (or null when absent) into { schema, problems }. */
|
|
259
|
+
function parseSchema(text) {
|
|
260
|
+
if (text == null) return { schema: undefined, problems: [] };
|
|
261
|
+
let raw;
|
|
262
|
+
try {
|
|
263
|
+
raw = JSON.parse(text);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return {
|
|
266
|
+
schema: undefined,
|
|
267
|
+
problems: [SCHEMA_FILE + " is not valid JSON (" + e.message + "); its rules are not being enforced."],
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
271
|
+
return { schema: undefined, problems: [SCHEMA_FILE + " must hold a JSON object; its rules are not being enforced."] };
|
|
272
|
+
}
|
|
273
|
+
const problems = [];
|
|
274
|
+
const declared = raw.article;
|
|
275
|
+
if (declared === undefined) return { schema: {}, problems };
|
|
276
|
+
if (typeof declared !== "object" || declared === null || Array.isArray(declared)) {
|
|
277
|
+
return { schema: undefined, problems: [SCHEMA_FILE + ': "article" must be an object; its rules are not being enforced.'] };
|
|
278
|
+
}
|
|
279
|
+
const schema = {};
|
|
280
|
+
for (const [name, value] of Object.entries(declared)) {
|
|
281
|
+
if (!SCHEMA_FIELDS.includes(name)) {
|
|
282
|
+
problems.push(SCHEMA_FILE + ': unknown article field "' + name + '" is ignored (fields: ' + SCHEMA_FIELDS.join(", ") + ").");
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
286
|
+
problems.push(SCHEMA_FILE + ': rule for "' + name + '" must be an object and is ignored.');
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const rule = {};
|
|
290
|
+
for (const [key, val] of Object.entries(value)) {
|
|
291
|
+
if (!SCHEMA_RULE_KEYS.includes(key)) {
|
|
292
|
+
problems.push(SCHEMA_FILE + ': unknown rule key "' + name + "." + key + '" is ignored (keys: ' + SCHEMA_RULE_KEYS.join(", ") + ").");
|
|
293
|
+
} else if (key === "required" && typeof val === "boolean") rule.required = val;
|
|
294
|
+
else if ((key === "min_chars" || key === "max_chars") && typeof val === "number" && Number.isInteger(val) && val >= 0) rule[key] = val;
|
|
295
|
+
else if (key === "hint" && typeof val === "string") rule.hint = val;
|
|
296
|
+
else problems.push(SCHEMA_FILE + ': "' + name + "." + key + '" has the wrong type and is ignored.');
|
|
297
|
+
}
|
|
298
|
+
schema[name] = rule;
|
|
299
|
+
}
|
|
300
|
+
return { schema, problems };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* The title the schema means: the body's leading # heading, nothing later. */
|
|
304
|
+
function titleOf(body) {
|
|
305
|
+
for (const line of body.split("\n")) {
|
|
306
|
+
if (!line.trim()) continue;
|
|
307
|
+
const m = /^#\s+(.+)$/.exec(line);
|
|
308
|
+
return m ? m[1].trim() : "";
|
|
309
|
+
}
|
|
310
|
+
return "";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* One save (or one read, when prevRaw === raw so nothing counts as touched)
|
|
314
|
+
against the schema. summaryKey is the frontmatter key this collection
|
|
315
|
+
carries its capsule under. A required violation rejects only when the save
|
|
316
|
+
touched that field or created the document; a legacy violation the save
|
|
317
|
+
left alone warns instead, so a body edit is never held hostage to an old
|
|
318
|
+
missing capsule. */
|
|
319
|
+
function checkDoc(raw, prevRaw, schema, summaryKey) {
|
|
320
|
+
const rejects = [];
|
|
321
|
+
const warns = [];
|
|
322
|
+
if (!schema) return { rejects, warns };
|
|
323
|
+
const created = prevRaw == null;
|
|
324
|
+
const extract = (text) => {
|
|
325
|
+
const { meta, body } = parseFrontmatter(text);
|
|
326
|
+
return {
|
|
327
|
+
capsule: String(meta[summaryKey] || "").trim(),
|
|
328
|
+
title: titleOf(body),
|
|
329
|
+
body: body.trim(),
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
const now = extract(raw);
|
|
333
|
+
const before = created ? null : extract(prevRaw);
|
|
334
|
+
for (const name of SCHEMA_FIELDS) {
|
|
335
|
+
const rule = schema[name];
|
|
336
|
+
if (!rule) continue;
|
|
337
|
+
const value = now[name];
|
|
338
|
+
const touched = created || value !== before[name];
|
|
339
|
+
const hint = rule.hint ? " (" + rule.hint + ")" : "";
|
|
340
|
+
if (rule.required && !value) {
|
|
341
|
+
if (touched) rejects.push(name + " is required" + hint);
|
|
342
|
+
else warns.push("schema: " + name + " is required and missing; this document can be healed by editing" + hint);
|
|
343
|
+
}
|
|
344
|
+
if (value && rule.min_chars != null && value.length < rule.min_chars) {
|
|
345
|
+
warns.push("schema: " + name + " is under " + rule.min_chars + " characters" + hint);
|
|
346
|
+
}
|
|
347
|
+
if (value && rule.max_chars != null && value.length > rule.max_chars) {
|
|
348
|
+
warns.push("schema: " + name + " is over " + rule.max_chars + " characters (" + value.length + ")" + hint);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return { rejects, warns };
|
|
352
|
+
}
|
|
353
|
+
|
|
227
354
|
function extractLinks(body) {
|
|
228
355
|
const out = [];
|
|
229
356
|
// Fenced code carries example links that were never authored as references.
|
|
@@ -265,7 +392,14 @@ var AtlasPipeline = (function () {
|
|
|
265
392
|
const allTags = asTags(meta[f.tags]);
|
|
266
393
|
const lift = config.preset === "pi-canon";
|
|
267
394
|
const fromPath = lift ? allTags.filter((t) => t.startsWith("path:")) : [];
|
|
395
|
+
/* A read never rejects: a document in violation carries heal notes. */
|
|
396
|
+
let schemaNotes;
|
|
397
|
+
if (config.schema && !col.immutable) {
|
|
398
|
+
const w = checkDoc(text, text, config.schema, f.summary).warns;
|
|
399
|
+
if (w.length) schemaNotes = w;
|
|
400
|
+
}
|
|
268
401
|
return {
|
|
402
|
+
schemaNotes,
|
|
269
403
|
path: rel,
|
|
270
404
|
address,
|
|
271
405
|
collection: col.name,
|
|
@@ -787,6 +921,7 @@ var AtlasPipeline = (function () {
|
|
|
787
921
|
preset: config.preset,
|
|
788
922
|
mode,
|
|
789
923
|
basis,
|
|
924
|
+
schemaProblems: config.schemaProblems && config.schemaProblems.length ? config.schemaProblems : undefined,
|
|
790
925
|
collections: config.collections.map((c) => ({
|
|
791
926
|
name: c.name,
|
|
792
927
|
match: c.match,
|
|
@@ -811,6 +946,7 @@ var AtlasPipeline = (function () {
|
|
|
811
946
|
exists: n.exists,
|
|
812
947
|
degree: n.degree,
|
|
813
948
|
rank: n.rank,
|
|
949
|
+
schemaNotes: n.schemaNotes,
|
|
814
950
|
html: n.body ? renderMarkdown(n.body) : "",
|
|
815
951
|
})),
|
|
816
952
|
edges,
|
|
@@ -832,6 +968,13 @@ var AtlasPipeline = (function () {
|
|
|
832
968
|
const raw = composeConfig(opts.configJson, () => detectPreset(md.map((f) => f.path)));
|
|
833
969
|
const config = buildConfig(raw);
|
|
834
970
|
config.vectors = parseVectors(opts.vectorsJson);
|
|
971
|
+
/* schemaTexts carries both candidate files ({ root, nested }); which one is
|
|
972
|
+
the store's contract depends on where the collections landed, so the
|
|
973
|
+
choice waits for the config. */
|
|
974
|
+
const texts = opts.schemaTexts || {};
|
|
975
|
+
const s = parseSchema((schemaPrefix(config) ? texts.nested : texts.root) ?? null);
|
|
976
|
+
config.schema = s.schema;
|
|
977
|
+
config.schemaProblems = s.problems;
|
|
835
978
|
const docs = scanFiles(md, config);
|
|
836
979
|
const graph = buildGraph(docs);
|
|
837
980
|
return buildData(config, graph, opts.mode || "fs");
|
|
@@ -839,12 +982,16 @@ var AtlasPipeline = (function () {
|
|
|
839
982
|
|
|
840
983
|
return {
|
|
841
984
|
CONFIG_NAME,
|
|
985
|
+
SCHEMA_FILE,
|
|
842
986
|
PI_CANON_PRESET,
|
|
843
987
|
DEFAULT_CONFIG,
|
|
844
988
|
buildConfig,
|
|
845
989
|
composeConfig,
|
|
846
990
|
detectPreset,
|
|
847
991
|
parseVectors,
|
|
992
|
+
parseSchema,
|
|
993
|
+
schemaPrefix,
|
|
994
|
+
checkDoc,
|
|
848
995
|
collectionFor,
|
|
849
996
|
parseFrontmatter,
|
|
850
997
|
extractLinks,
|
package/src/ui/shell.js
CHANGED
|
@@ -193,10 +193,29 @@
|
|
|
193
193
|
.catch(function () { return null; });
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/* Raw text at a nested path under the handle, or null when absent. */
|
|
197
|
+
function readTextAt(parts) {
|
|
198
|
+
var dir = Promise.resolve(handle);
|
|
199
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
200
|
+
(function (name) {
|
|
201
|
+
dir = dir.then(function (d) { return d.getDirectoryHandle(name); });
|
|
202
|
+
})(parts[i]);
|
|
203
|
+
}
|
|
204
|
+
return dir
|
|
205
|
+
.then(function (d) { return d.getFileHandle(parts[parts.length - 1]); })
|
|
206
|
+
.then(function (fh) { return fh.getFile(); })
|
|
207
|
+
.then(function (f) { return f.text(); })
|
|
208
|
+
.catch(function () { return null; });
|
|
209
|
+
}
|
|
210
|
+
|
|
196
211
|
/* "fs" when the handle came with write permission, "browse" when it is
|
|
197
212
|
read-only (a dropped folder whose write grant was declined still reads). */
|
|
198
213
|
var handleMode = "fs";
|
|
199
214
|
|
|
215
|
+
/* The store's article contract, parsed after each mount so the write path
|
|
216
|
+
enforces the same rules the wire data's heal notes report. */
|
|
217
|
+
var storeSchema = { schema: undefined, problems: [] };
|
|
218
|
+
|
|
200
219
|
function buildFromFolder(progress) {
|
|
201
220
|
return listDir(handle, "", [])
|
|
202
221
|
.then(function (list) {
|
|
@@ -206,12 +225,20 @@
|
|
|
206
225
|
.then(function (files) {
|
|
207
226
|
return readJson(CONFIG).then(function (configJson) {
|
|
208
227
|
var vecName = (configJson && configJson.embeddings) || "embeddings.json";
|
|
209
|
-
return
|
|
228
|
+
return Promise.all([
|
|
229
|
+
readJson(vecName),
|
|
230
|
+
readTextAt(["schema.json"]),
|
|
231
|
+
readTextAt([".canon", "schema.json"]),
|
|
232
|
+
]).then(function (loaded) {
|
|
233
|
+
var schemaTexts = { root: loaded[1], nested: loaded[2] };
|
|
210
234
|
currentData = AtlasPipeline.buildWireData(files, {
|
|
211
235
|
configJson: configJson,
|
|
212
|
-
vectorsJson:
|
|
236
|
+
vectorsJson: loaded[0],
|
|
237
|
+
schemaTexts: schemaTexts,
|
|
213
238
|
mode: handleMode,
|
|
214
239
|
});
|
|
240
|
+
var nested = currentData.collections.some(function (c) { return (c.match || "").indexOf(".canon/") === 0; });
|
|
241
|
+
storeSchema = AtlasPipeline.parseSchema(nested ? schemaTexts.nested : schemaTexts.root);
|
|
215
242
|
return currentData;
|
|
216
243
|
});
|
|
217
244
|
});
|
|
@@ -252,6 +279,21 @@
|
|
|
252
279
|
});
|
|
253
280
|
}
|
|
254
281
|
|
|
282
|
+
/* The same asymmetric enforcement the server applies: a required violation
|
|
283
|
+
the save touched (or a create) rejects with nothing written, so the editor
|
|
284
|
+
keeps the text and says what to correct; everything else is a warning. */
|
|
285
|
+
function schemaGateFs(content, prevRaw, col) {
|
|
286
|
+
if (col.immutable) return [];
|
|
287
|
+
/* A broken declaration enforces nothing, but every write says so. */
|
|
288
|
+
if (!storeSchema.schema) return storeSchema.problems || [];
|
|
289
|
+
var summaryKey = (col.fields && col.fields.summary) || "summary";
|
|
290
|
+
var r = AtlasPipeline.checkDoc(String(content == null ? "" : content), prevRaw, storeSchema.schema, summaryKey);
|
|
291
|
+
if (r.rejects.length) {
|
|
292
|
+
throw new Error("Write rejected by this store's schema.json:\n- " + r.rejects.join("\n- "));
|
|
293
|
+
}
|
|
294
|
+
return r.warns.concat(storeSchema.problems);
|
|
295
|
+
}
|
|
296
|
+
|
|
255
297
|
var fsBackend = {
|
|
256
298
|
graph: function () { return buildFromFolder(); },
|
|
257
299
|
readDoc: function (path) {
|
|
@@ -264,18 +306,25 @@
|
|
|
264
306
|
writeDoc: function (path, content) {
|
|
265
307
|
var col = confine(path);
|
|
266
308
|
if (col.immutable) return Promise.reject(new Error(col.name + " is immutable"));
|
|
267
|
-
return fileHandleFor(path, false)
|
|
268
|
-
|
|
269
|
-
|
|
309
|
+
return fileHandleFor(path, false)
|
|
310
|
+
.then(function (fh) {
|
|
311
|
+
return fh.getFile().then(function (f) { return f.text(); }).then(function (prev) {
|
|
312
|
+
var warnings = schemaGateFs(content, prev, col);
|
|
313
|
+
return writeThrough(fh, content).then(function () {
|
|
314
|
+
return warnings.length ? { path: path, warnings: warnings } : { path: path };
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
|
270
318
|
},
|
|
271
319
|
createDoc: function (path, content) {
|
|
272
|
-
confine(path);
|
|
320
|
+
var col = confine(path);
|
|
273
321
|
// Refuse to overwrite: succeed only if the file does not already exist.
|
|
274
322
|
return fileHandleFor(path, false).then(
|
|
275
323
|
function () { throw new Error("document already exists"); },
|
|
276
324
|
function () {
|
|
325
|
+
var warnings = schemaGateFs(content, null, col);
|
|
277
326
|
return fileHandleFor(path, true).then(function (fh) { return writeThrough(fh, content); }).then(function () {
|
|
278
|
-
return { path: path };
|
|
327
|
+
return warnings.length ? { path: path, warnings: warnings } : { path: path };
|
|
279
328
|
});
|
|
280
329
|
}
|
|
281
330
|
);
|
|
@@ -361,9 +410,15 @@
|
|
|
361
410
|
).then(function (docs) {
|
|
362
411
|
var mem = {};
|
|
363
412
|
docs.forEach(function (f) { mem[f.path] = f.text; });
|
|
364
|
-
|
|
413
|
+
return Promise.all([textOf("schema.json"), textOf(".canon/schema.json")]).then(function (st) {
|
|
414
|
+
return { docs: docs, mem: mem, schemaTexts: { root: st[0], nested: st[1] } };
|
|
415
|
+
});
|
|
416
|
+
}).then(function (got) {
|
|
417
|
+
var mem = got.mem;
|
|
418
|
+
var data = AtlasPipeline.buildWireData(got.docs, {
|
|
365
419
|
configJson: configJson,
|
|
366
420
|
vectorsJson: vectorsJson,
|
|
421
|
+
schemaTexts: got.schemaTexts,
|
|
367
422
|
mode: "browse",
|
|
368
423
|
});
|
|
369
424
|
currentData = data;
|
package/src/ui/style.css
CHANGED
|
@@ -555,4 +555,7 @@ select.abtn option { background: var(--bg-elevated); color: var(--text); }
|
|
|
555
555
|
|
|
556
556
|
.abtn.danger:hover { border-color: #8b2942; color: #e08a9c; }
|
|
557
557
|
|
|
558
|
-
.err { margin: 0.5rem 0; font-size: 0.75rem; color: #e08a9c; }
|
|
558
|
+
.err { margin: 0.5rem 0; font-size: 0.75rem; color: #e08a9c; white-space: pre-line; }
|
|
559
|
+
|
|
560
|
+
/* Schema heal notes: information beside a document, never a wall. */
|
|
561
|
+
.schema-note { margin: 0.35rem 0 0.6rem; font-size: 0.72rem; color: #a89a5f; white-space: pre-line; }
|