yamlover 0.3.2 → 0.3.4
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/server.js +370 -109
- package/package.json +1 -1
- package/src/client/App.tsx +59 -7
- package/src/client/api.ts +45 -18
- package/src/client/render.tsx +16 -3
- package/src/client/renderers/annotate.tsx +173 -53
- package/src/client/renderers/asciidoc.tsx +2 -1
- package/src/client/renderers/chapter.tsx +5 -1
- package/src/client/renderers/csv.tsx +2 -1
- package/src/client/renderers/djvu.tsx +8 -1
- package/src/client/renderers/explorer.tsx +90 -5
- package/src/client/renderers/imagemap.tsx +19 -6
- package/src/client/renderers/latex.tsx +2 -1
- package/src/client/renderers/marklower.tsx +2 -1
- package/src/client/renderers/pdf.tsx +9 -1
- package/src/client/renderers/plantuml.tsx +2 -1
- package/src/client/renderers/registry.tsx +91 -69
- package/src/client/renderers/text.tsx +2 -1
- package/src/client/styles.css +40 -3
- package/src/server/embed.ts +187 -0
- package/src/server/engine-api.ts +243 -122
- package/src/server/node-kind.ts +15 -2
package/src/server/engine-api.ts
CHANGED
|
@@ -37,12 +37,13 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
37
37
|
import { Store, reindex, reindexAsync, hashFileAsync, watchTree, loadSettings, mv, relinkMoved, evalQuery } from "../../../engine/ts/src/index.ts";
|
|
38
38
|
import type { NodeRow, EdgeRow, Settings, IndexDiff } from "../../../engine/ts/src/index.ts";
|
|
39
39
|
import { parseYamlover } from "../../../parser/ts/src/yamlover.ts";
|
|
40
|
-
import { pointerToken
|
|
40
|
+
import { pointerToken } from "../../../parser/ts/src/serialize-yamlover.ts";
|
|
41
|
+
import { appendAnnotation, upsertFragment, removeAnnotation as removeAnnotationItem, keyToken } from "./embed.js";
|
|
41
42
|
import { colonSegment } from "../../../parser/ts/src/pointer.ts";
|
|
42
43
|
import { isPointer } from "../../../parser/ts/src/ir.ts";
|
|
43
44
|
import type { Node as IrNode } from "../../../parser/ts/src/ir.ts";
|
|
44
45
|
import { buildGitIgnore } from "./gitignore.js";
|
|
45
|
-
import { displayKind, ownedEntries, typeName } from "./node-kind.js";
|
|
46
|
+
import { displayKind, ownedEntries, typeName, facetsOf } from "./node-kind.js";
|
|
46
47
|
import { TaskRegistry } from "./tasks.js";
|
|
47
48
|
import type { TaskHandle } from "./tasks.js";
|
|
48
49
|
|
|
@@ -311,31 +312,48 @@ export function createHandlers(dataRoot: string, opts: Options = {}): Handler &
|
|
|
311
312
|
return;
|
|
312
313
|
}
|
|
313
314
|
|
|
314
|
-
// Create an annotation —
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
// tag's JSON paths; no selector applies the tag to the WHOLE node.
|
|
315
|
+
// Create an annotation — TAG a target (a WRITE path; ANNOTATIONS.md). The tag application is
|
|
316
|
+
// appended to the target's own `yamlover-annotations` array, embedded in the target's host
|
|
317
|
+
// body (a `*.yamlover` document, or a directory's `.yamlover/body.yamlover` overlay keyed by
|
|
318
|
+
// filename). The target may be a whole node OR a fragment (`…:yamlover-fragments:<slug>`).
|
|
319
|
+
// Body: { target, tag, description?, params? } — target/tag are JSON paths; description/params
|
|
320
|
+
// make it a PARAMETRIZED annotation (an object element), else it is a bare tag pointer.
|
|
321
321
|
if (req.method === "POST" && url.pathname === "/api/annotate") {
|
|
322
322
|
readBody(req)
|
|
323
323
|
.then((data) =>
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
enqueue(() => {
|
|
327
|
-
const a = data as AnnotationInput;
|
|
324
|
+
enqueue(async () => {
|
|
325
|
+
const a = data as AnnotateInput;
|
|
328
326
|
const tagStore = storePath(strToSegs(a.tag ?? ""));
|
|
329
327
|
if (!a?.tag || s.node(tagStore)?.format !== TAG_FORMAT) {
|
|
330
328
|
throw new Error("annotation needs a `tag` that is an x-yamlover-tag node");
|
|
331
329
|
}
|
|
332
|
-
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
330
|
+
embedAnnotation(dataRoot, s, a);
|
|
331
|
+
// A surgical body edit changes a file's hash; the manifest-cached reconcile re-reads
|
|
332
|
+
// only the edited body (the /api/paste pattern), so the graph trues up in one pass.
|
|
333
|
+
broadcast(await doReindex());
|
|
334
|
+
scheduleHasher();
|
|
335
|
+
return { ok: true };
|
|
336
|
+
}),
|
|
337
|
+
)
|
|
338
|
+
.then((body) => sendJson(res, 201, body))
|
|
339
|
+
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Create a FRAGMENT — a user-marked region inside a target (a WRITE path; ANNOTATIONS.md).
|
|
344
|
+
// Stored under the target's `yamlover-fragments` mapping keyed by a fresh slug; for an
|
|
345
|
+
// image-like selection the optional `imageBase64` crop is written as a sidecar blob the
|
|
346
|
+
// fragment references. Body: { target, selector, imageBase64? } → { slug, fragmentPath }.
|
|
347
|
+
if (req.method === "POST" && url.pathname === "/api/fragment") {
|
|
348
|
+
readBody(req)
|
|
349
|
+
.then((data) =>
|
|
350
|
+
enqueue(async () => {
|
|
351
|
+
const f = data as FragmentInput;
|
|
352
|
+
if (!f?.selector || typeof f.selector !== "object") throw new Error("a fragment needs a selector");
|
|
353
|
+
const made = embedFragment(dataRoot, s, f);
|
|
354
|
+
broadcast(await doReindex());
|
|
355
|
+
scheduleHasher();
|
|
356
|
+
return made;
|
|
339
357
|
}),
|
|
340
358
|
)
|
|
341
359
|
.then((body) => sendJson(res, 201, body))
|
|
@@ -343,15 +361,15 @@ export function createHandlers(dataRoot: string, opts: Options = {}): Handler &
|
|
|
343
361
|
return;
|
|
344
362
|
}
|
|
345
363
|
|
|
346
|
-
// Delete an annotation
|
|
347
|
-
// the
|
|
348
|
-
// lives: the guard is its schema (`x-yamlover-annotation`), not a directory.
|
|
364
|
+
// Delete an annotation (recolor = delete + create, client-side): remove the matching element
|
|
365
|
+
// from the target's `yamlover-annotations`. Body/query: { target, tag } (JSON paths).
|
|
349
366
|
if (req.method === "DELETE" && url.pathname === "/api/annotate") {
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
367
|
+
const target = url.searchParams.get("target") ?? "";
|
|
368
|
+
const tag = url.searchParams.get("tag") || "";
|
|
369
|
+
enqueue(async () => {
|
|
370
|
+
if (!tag) throw new Error("delete needs a `tag`");
|
|
371
|
+
unembedAnnotation(dataRoot, s, target, tag);
|
|
372
|
+
broadcast(await doReindex());
|
|
355
373
|
})
|
|
356
374
|
.then(() => sendJson(res, 200, { ok: true }))
|
|
357
375
|
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
@@ -502,6 +520,7 @@ export function createHandlers(dataRoot: string, opts: Options = {}): Handler &
|
|
|
502
520
|
path: segsToStr(segs),
|
|
503
521
|
type: tocType(s, p, row),
|
|
504
522
|
format: row.format ?? null,
|
|
523
|
+
...facetsOf(s, p, row), // valueType / hasKeyed / hasOrdinal — the renderer dispatch facets (TYPES.md §9)
|
|
505
524
|
concrete: concreteOf(dataRoot, segs, row), // dir | yamlover | null (stat-derived; engine tracks no per-node concrete yet)
|
|
506
525
|
documentPath: documentPath(s, segs), // nearest enclosing document root (for `/…` links)
|
|
507
526
|
title: titleOf(s, p),
|
|
@@ -675,7 +694,7 @@ function linkMarker(dataRoot: string, s: Store, segs: Seg[]): Record<string, unk
|
|
|
675
694
|
const p = storePath(segs);
|
|
676
695
|
const row = s.node(p)!;
|
|
677
696
|
const k = displayKind(s, p, row);
|
|
678
|
-
const info: Record<string, unknown> = { kind: k, type: tocType(s, p, row), path: segsToStr(segs) };
|
|
697
|
+
const info: Record<string, unknown> = { kind: k, type: tocType(s, p, row), ...facetsOf(s, p, row), path: segsToStr(segs) };
|
|
679
698
|
if (row.format) info.format = row.format;
|
|
680
699
|
const concrete = concreteOf(dataRoot, segs, row);
|
|
681
700
|
if (concrete) info.concrete = concrete; // a folder child renders with a folder icon
|
|
@@ -752,6 +771,7 @@ function binaryContent(dataRoot: string, segs: Seg[], row: NodeRow): Record<stri
|
|
|
752
771
|
|
|
753
772
|
interface TreeNode {
|
|
754
773
|
path: string; label: string; type: string; format: string | null;
|
|
774
|
+
valueType?: string | null; hasKeyed?: boolean; hasOrdinal?: boolean; // renderer dispatch facets (TYPES.md §9)
|
|
755
775
|
concrete: string | null; hasChildren: boolean; children: TreeNode[];
|
|
756
776
|
}
|
|
757
777
|
|
|
@@ -764,6 +784,7 @@ function buildTree(dataRoot: string, s: Store, segs: Seg[], label: string, depth
|
|
|
764
784
|
label,
|
|
765
785
|
type: tocType(s, p, row),
|
|
766
786
|
format: row.format ?? null,
|
|
787
|
+
...facetsOf(s, p, row),
|
|
767
788
|
concrete: concreteOf(dataRoot, segs, row),
|
|
768
789
|
hasChildren: s.hasChildren(p),
|
|
769
790
|
children: [],
|
|
@@ -785,75 +806,123 @@ function labelFor(s: Store, p: string, keyOrIdx: Seg): string {
|
|
|
785
806
|
}
|
|
786
807
|
|
|
787
808
|
// --------------------------------------------------------------------------- //
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
809
|
+
// Tags, fragments & annotations — EMBEDDED in the target (ANNOTATIONS.md). A user-marked region
|
|
810
|
+
// is a FRAGMENT under the target's `yamlover-fragments` mapping (keyed by slug; selector + an
|
|
811
|
+
// optional binary crop). TAGGING a target — a whole node or a fragment — appends to its
|
|
812
|
+
// `yamlover-annotations` array: a bare tag pointer (`- *::tag`) or a `{tag, …params}` object. The
|
|
813
|
+
// applied tag drives the color. A material's annotations / a tag's materials are derived from
|
|
814
|
+
// these forward `*` edges. Writes edit the target's host body (a `*.yamlover` doc or a directory
|
|
815
|
+
// `.yamlover/body.yamlover` overlay) surgically — see ./embed.ts.
|
|
791
816
|
// --------------------------------------------------------------------------- //
|
|
792
817
|
|
|
793
818
|
const TAG_FORMAT = "x-yamlover-tag";
|
|
819
|
+
const ANN_KEY = "yamlover-annotations";
|
|
820
|
+
const FRAG_KEY = "yamlover-fragments";
|
|
821
|
+
const CROP_DIR = "fragments"; // crop sidecar blobs live here (a normal, indexable dir) at the served root
|
|
822
|
+
|
|
823
|
+
interface AnnotateInput {
|
|
824
|
+
target: string; // the target's JSON path — a node, or a fragment (`…:yamlover-fragments:<slug>`)
|
|
825
|
+
tag: string; // the applied tag's JSON path
|
|
826
|
+
description?: string; // a parametrized annotation's comment
|
|
827
|
+
params?: Record<string, unknown>; // any other parameters (parametrized form)
|
|
828
|
+
}
|
|
794
829
|
|
|
795
|
-
interface
|
|
796
|
-
target: string; // the
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
);
|
|
809
|
-
|
|
810
|
-
const segs = storePathToSegs(e.to);
|
|
811
|
-
const color = s.node(e.to + ":color")?.value;
|
|
830
|
+
interface FragmentInput {
|
|
831
|
+
target: string; // the node the region lives in (its JSON path)
|
|
832
|
+
selector: Record<string, unknown>; // { type:"text", exact, … } | { type:"pdf", page, x, y, w, h } | …
|
|
833
|
+
imageBase64?: string; // an optional PNG crop (image-like selections)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** A child store-path: `parent` + `:key` (root `:` has no leading owner). */
|
|
837
|
+
const childPath = (parent: string, key: string): string => (parent === ":" ? "" : parent) + ":" + key;
|
|
838
|
+
|
|
839
|
+
/** A tag store-path projected as { path, name, color } — color = its explicit `color`, else null
|
|
840
|
+
* (the client derives a hue from the name). Null when `tagStore` is not an x-yamlover-tag node. */
|
|
841
|
+
function projectTag(s: Store, tagStore: string): { path: string; name: string; color: string | null } | null {
|
|
842
|
+
if (s.node(tagStore)?.format !== TAG_FORMAT) return null;
|
|
843
|
+
const segs = storePathToSegs(tagStore);
|
|
844
|
+
const color = s.node(tagStore + ":color")?.value;
|
|
812
845
|
return { path: segsToStr(segs), name: String(segs[segs.length - 1] ?? ""), color: typeof color === "string" ? color : null };
|
|
813
846
|
}
|
|
814
847
|
|
|
815
|
-
/** The
|
|
816
|
-
*
|
|
817
|
-
*
|
|
848
|
+
/** The tag applications in a host node's `yamlover-annotations` array: a bare tag pointer (a `ref`
|
|
849
|
+
* entry straight to the tag) or a `{tag, …params}` object (a `contain` entry whose `tag` field
|
|
850
|
+
* refs the tag and whose scalar children are parameters). */
|
|
851
|
+
function readAnnotations(s: Store, hostStore: string): { tag: ReturnType<typeof projectTag>; description?: string; params?: Record<string, unknown> }[] {
|
|
852
|
+
const arr = childPath(hostStore, ANN_KEY);
|
|
853
|
+
if (!s.node(arr)) return [];
|
|
854
|
+
const out: { tag: ReturnType<typeof projectTag>; description?: string; params?: Record<string, unknown> }[] = [];
|
|
855
|
+
for (const e of s.entries(arr)) {
|
|
856
|
+
if (e.kind === "ref") {
|
|
857
|
+
const tag = projectTag(s, e.to);
|
|
858
|
+
if (tag) out.push({ tag });
|
|
859
|
+
} else if (e.kind === "contain") {
|
|
860
|
+
const tagEdge = s.relationships(e.to).out.find((o) => o.kind === "ref" && o.label === "tag");
|
|
861
|
+
const tag = tagEdge ? projectTag(s, tagEdge.to) : null;
|
|
862
|
+
if (!tag) continue;
|
|
863
|
+
const params: Record<string, unknown> = {};
|
|
864
|
+
let description: string | undefined;
|
|
865
|
+
for (const c of s.children(e.to)) {
|
|
866
|
+
const v = s.node(c.to)?.value;
|
|
867
|
+
if (c.label === "description") description = v == null ? undefined : String(v);
|
|
868
|
+
else if (c.label) params[c.label] = v;
|
|
869
|
+
}
|
|
870
|
+
out.push({ tag, description, params: Object.keys(params).length ? params : undefined });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return out;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** A host node's fragments: each slug's selector fields (geometry / text quote) + its crop URL,
|
|
877
|
+
* read from the `yamlover-fragments` mapping. `image` is a `*` pointer (a ref edge) to the crop. */
|
|
878
|
+
function readFragments(s: Store, hostStore: string): { slug: string; node: string; selector: Record<string, unknown>; imageUrl?: string }[] {
|
|
879
|
+
const frags = childPath(hostStore, FRAG_KEY);
|
|
880
|
+
if (!s.node(frags)) return [];
|
|
881
|
+
const out: { slug: string; node: string; selector: Record<string, unknown>; imageUrl?: string }[] = [];
|
|
882
|
+
for (const fc of s.children(frags)) {
|
|
883
|
+
if (!fc.label) continue;
|
|
884
|
+
const selector: Record<string, unknown> = {};
|
|
885
|
+
for (const c of s.children(fc.to)) {
|
|
886
|
+
if (c.label && c.label !== ANN_KEY && c.label !== "created") selector[c.label] = s.node(c.to)?.value;
|
|
887
|
+
}
|
|
888
|
+
const imgEdge = s.relationships(fc.to).out.find((o) => o.kind === "ref" && o.label === "image");
|
|
889
|
+
const imageUrl = imgEdge ? `/api/blob?path=${encodeURIComponent(segsToStr(storePathToSegs(imgEdge.to)))}` : undefined;
|
|
890
|
+
out.push({ slug: fc.label, node: fc.to, selector, imageUrl });
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** The annotations ON this material: its own whole-node tags, plus each fragment's tags carrying
|
|
896
|
+
* that fragment's selector + crop (so the client highlights the region and colors by tag). */
|
|
818
897
|
function annotationsFor(dataRoot: string, s: Store, segs: Seg[]): unknown[] {
|
|
898
|
+
void dataRoot;
|
|
819
899
|
const p = storePath(segs);
|
|
820
900
|
const out: unknown[] = [];
|
|
821
|
-
for (const
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
out.push({
|
|
827
|
-
path: segsToStr(aSegs),
|
|
828
|
-
tag: appliedTag(s, e.from),
|
|
829
|
-
...(projectValue(dataRoot, s, aSegs, 6, true) as Record<string, unknown>),
|
|
830
|
-
});
|
|
901
|
+
for (const a of readAnnotations(s, p)) out.push({ ...a });
|
|
902
|
+
for (const f of readFragments(s, p)) {
|
|
903
|
+
for (const a of readAnnotations(s, f.node)) {
|
|
904
|
+
out.push({ ...a, selector: f.selector, fragmentSlug: f.slug, ...(f.imageUrl ? { imageUrl: f.imageUrl } : {}) });
|
|
905
|
+
}
|
|
831
906
|
}
|
|
832
907
|
return out;
|
|
833
908
|
}
|
|
834
909
|
|
|
835
|
-
/** The MATERIALS filed under a tag —
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
* they never appear here. Ordered lexicographically by the member's path, like
|
|
840
|
-
* {@link downstreamEntries}' back-edge tail. */
|
|
910
|
+
/** The MATERIALS filed under a tag — the reverse of the forward `*::tag` pointers authored in
|
|
911
|
+
* `yamlover-annotations` arrays (a bare element's edge from the array, an object element's `tag`
|
|
912
|
+
* field, or a legacy direct `~`/`&` membership). Each is climbed to its owning material or
|
|
913
|
+
* fragment and deduped, ordered lexicographically by path. */
|
|
841
914
|
function taggedMaterials(dataRoot: string, s: Store, tagStorePath: string): unknown[] {
|
|
842
915
|
const seen = new Set<string>();
|
|
843
916
|
const out: unknown[] = [];
|
|
844
|
-
const
|
|
845
|
-
.filter((e) => e.kind === "back" && e.from)
|
|
917
|
+
const ins = s.relationships(tagStorePath).in
|
|
918
|
+
.filter((e) => (e.kind === "ref" || e.kind === "back") && e.from)
|
|
846
919
|
.sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
847
|
-
for (const e of
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
if (seen.has(material) || !s.node(material)) continue;
|
|
855
|
-
seen.add(material);
|
|
856
|
-
out.push(linkMarker(dataRoot, s, storePathToSegs(material)));
|
|
920
|
+
for (const e of ins) {
|
|
921
|
+
const arrOwner = e.from.replace(/\[\d+\]$/, "").match(/^(.*):yamlover-annotations$/);
|
|
922
|
+
const owner = arrOwner ? arrOwner[1] || ":" : e.from; // an annotation array → its host; else a direct member
|
|
923
|
+
if (owner === tagStorePath || seen.has(owner) || !s.node(owner)) continue;
|
|
924
|
+
seen.add(owner);
|
|
925
|
+
out.push(linkMarker(dataRoot, s, storePathToSegs(owner)));
|
|
857
926
|
}
|
|
858
927
|
return out;
|
|
859
928
|
}
|
|
@@ -874,30 +943,97 @@ function yScalar(v: unknown): string {
|
|
|
874
943
|
return typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(String(v ?? ""));
|
|
875
944
|
}
|
|
876
945
|
|
|
877
|
-
/**
|
|
878
|
-
*
|
|
879
|
-
* path
|
|
880
|
-
* the
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
946
|
+
/** The yamlover host body holding the node at `segs`, and the mapping-key path WITHIN it to that
|
|
947
|
+
* node (ANNOTATIONS.md §3). A standalone `*.yamlover` document → the file itself (within = the
|
|
948
|
+
* path inside it); a directory → its `.yamlover/body.yamlover` overlay; an on-disk blob (a PDF) →
|
|
949
|
+
* the ENCLOSING directory's overlay, keyed by the filename. */
|
|
950
|
+
function hostFor(dataRoot: string, s: Store, segs: Seg[]): { bodyFile: string; within: string[] } {
|
|
951
|
+
for (let i = segs.length; i >= 0; i--) {
|
|
952
|
+
const sub = segs.slice(0, i);
|
|
953
|
+
const abs = path.resolve(dataRoot, ...sub.map(String));
|
|
954
|
+
let st: fs.Stats | undefined;
|
|
955
|
+
try { st = fs.statSync(abs); } catch { continue; }
|
|
956
|
+
if (st.isDirectory()) return { bodyFile: path.join(abs, ".yamlover", "body.yamlover"), within: segs.slice(i).map(String) };
|
|
957
|
+
if (st.isFile()) {
|
|
958
|
+
const node = s.node(storePath(sub));
|
|
959
|
+
// Edit a MAPPING document in place (a new top-level key is valid). A leaf file — scalar,
|
|
960
|
+
// blob, or array — would become an UNTAGGED omni/mix if a key were appended to its source
|
|
961
|
+
// (a parse error under the current parser), so route it through the enclosing directory's
|
|
962
|
+
// overlay keyed by the filename: the engine merges the fields onto the file at IR level
|
|
963
|
+
// (augmentEntry — omni-blob), never reparsing a mixed source. ANNOTATIONS.md §3.
|
|
964
|
+
if (node?.meta?.documentRoot && node.type === "mapping" && !node.is_array) {
|
|
965
|
+
return { bodyFile: abs, within: segs.slice(i).map(String) };
|
|
966
|
+
}
|
|
967
|
+
const dir = path.resolve(dataRoot, ...sub.slice(0, -1).map(String));
|
|
968
|
+
return { bodyFile: path.join(dir, ".yamlover", "body.yamlover"), within: segs.slice(i - 1).map(String) };
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return { bodyFile: path.join(dataRoot, ".yamlover", "body.yamlover"), within: segs.map(String) };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** One `yamlover-annotations` element's source lines at the list `indent`: a bare tag pointer when
|
|
975
|
+
* there are no parameters, else a `{tag, …}` object (block form). */
|
|
976
|
+
function annotationItemLines(a: AnnotateInput, indent: number): string[] {
|
|
977
|
+
const pad = " ".repeat(indent);
|
|
978
|
+
const ptr = pointerToken(pointerRaw(a.tag));
|
|
979
|
+
const params: Record<string, unknown> = { ...(a.params ?? {}) };
|
|
980
|
+
if (a.description != null && a.description !== "") params.description = a.description;
|
|
981
|
+
const keys = Object.keys(params);
|
|
982
|
+
if (keys.length === 0) return [`${pad}- ${ptr}`];
|
|
983
|
+
return [`${pad}- tag: ${ptr}`, ...keys.map((k) => `${pad} ${keyToken(k)}: ${yScalar(params[k])}`)];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/** A fragment's source lines at the fragments-map `indent` (`<slug>:` + selector + crop + created),
|
|
987
|
+
* tagged so it indexes as an x-yamlover-fragment node. */
|
|
988
|
+
function fragmentBlockLines(slug: string, selector: Record<string, unknown>, imagePtr: string | null, indent: number): string[] {
|
|
989
|
+
const pad = " ".repeat(indent);
|
|
990
|
+
const lines = [`${pad}${keyToken(slug)}: !!<*::yamlover:$defs:fragment>`];
|
|
991
|
+
for (const [k, v] of Object.entries(selector)) lines.push(`${pad} ${keyToken(k)}: ${yScalar(v)}`);
|
|
992
|
+
if (imagePtr) lines.push(`${pad} image: ${imagePtr}`);
|
|
993
|
+
lines.push(`${pad} created: ${new Date().toISOString()}`);
|
|
994
|
+
return lines;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** Embed a tag application into the target's `yamlover-annotations` array (editing the target's
|
|
998
|
+
* host body in place — ANNOTATIONS.md). */
|
|
999
|
+
function embedAnnotation(dataRoot: string, s: Store, a: AnnotateInput): void {
|
|
1000
|
+
const { bodyFile, within } = hostFor(dataRoot, s, strToSegs(a.target || ":"));
|
|
1001
|
+
fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
|
|
1002
|
+
const src = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
|
|
1003
|
+
fs.writeFileSync(bodyFile, appendAnnotation(src, within, (indent) => annotationItemLines(a, indent)));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Embed a fragment under the target's `yamlover-fragments` mapping; for an image-like selection,
|
|
1007
|
+
* write the PNG crop as a sidecar blob the fragment references. Returns its slug + node path. */
|
|
1008
|
+
function embedFragment(dataRoot: string, s: Store, f: FragmentInput): { slug: string; fragmentPath: string } {
|
|
1009
|
+
const segs = strToSegs(f.target || ":");
|
|
1010
|
+
const { bodyFile, within } = hostFor(dataRoot, s, segs);
|
|
1011
|
+
const slug = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1012
|
+
let imagePtr: string | null = null;
|
|
1013
|
+
if (f.imageBase64) {
|
|
1014
|
+
const bytes = Buffer.from(String(f.imageBase64).replace(/^data:[^,]*,/, ""), "base64");
|
|
1015
|
+
if (bytes.length > 0) {
|
|
1016
|
+
const cropDir = path.join(dataRoot, CROP_DIR);
|
|
1017
|
+
fs.mkdirSync(cropDir, { recursive: true });
|
|
1018
|
+
const cropName = `${slug}.png`;
|
|
1019
|
+
writeInside(dataRoot, cropDir, cropName, bytes);
|
|
1020
|
+
imagePtr = pointerToken(pointerRaw(segsToStr([CROP_DIR, cropName])));
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
fs.mkdirSync(path.dirname(bodyFile), { recursive: true });
|
|
1024
|
+
const src = fs.existsSync(bodyFile) ? fs.readFileSync(bodyFile, "utf8") : "";
|
|
1025
|
+
fs.writeFileSync(bodyFile, upsertFragment(src, within, slug, (indent) => fragmentBlockLines(slug, f.selector, imagePtr, indent)));
|
|
1026
|
+
return { slug, fragmentPath: segsToStr([...segs, FRAG_KEY, slug]) };
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Remove a tag application from the target's `yamlover-annotations` array — the first element
|
|
1030
|
+
* referencing `tag` (bare pointer or object `tag:` field). */
|
|
1031
|
+
function unembedAnnotation(dataRoot: string, s: Store, target: string, tag: string): void {
|
|
1032
|
+
const { bodyFile, within } = hostFor(dataRoot, s, strToSegs(target || ":"));
|
|
1033
|
+
if (!fs.existsSync(bodyFile)) return;
|
|
1034
|
+
const needle = pointerRaw(tag); // ::path:to:tag — present in both the bare and object forms
|
|
1035
|
+
const src = fs.readFileSync(bodyFile, "utf8");
|
|
1036
|
+
fs.writeFileSync(bodyFile, removeAnnotationItem(src, within, (itemText) => itemText.includes(needle)));
|
|
901
1037
|
}
|
|
902
1038
|
|
|
903
1039
|
/** Persist a NEW named tag as a key of the tag-taxonomy body at the project's default tags
|
|
@@ -918,7 +1054,7 @@ function writeTag(
|
|
|
918
1054
|
const createdFile = !fs.existsSync(file);
|
|
919
1055
|
const head = "# Named tags created from the annotation picker (settings.yamlover: tags.location).\n";
|
|
920
1056
|
const existing = createdFile ? head : fs.readFileSync(file, "utf8");
|
|
921
|
-
const body = (existing === "" || existing.endsWith("\n") ? existing : existing + "\n") + `${name}:
|
|
1057
|
+
const body = (existing === "" || existing.endsWith("\n") ? existing : existing + "\n") + `${name}: !!<*::yamlover:$defs:tag>\n`;
|
|
922
1058
|
const entries = parseYamlover(body, file).root.entries ?? [];
|
|
923
1059
|
const pos = entries.findIndex((e) => e.key === name);
|
|
924
1060
|
const entry = pos >= 0 ? entries[pos] : undefined;
|
|
@@ -930,21 +1066,6 @@ function writeTag(
|
|
|
930
1066
|
return { node: entry.value, pos, file: [...strToSegs(location).map(String), ".yamlover", "body.yamlover"].join("/"), createdFile };
|
|
931
1067
|
}
|
|
932
1068
|
|
|
933
|
-
/** Delete an annotation file given its node path. The guard is the GRAPH, not a directory: the
|
|
934
|
-
* node must be indexed as an `x-yamlover-annotation` and be a whole standalone `.yamlover` file
|
|
935
|
-
* (an annotation authored inline in a shared document cannot be deleted this way), inside the
|
|
936
|
-
* served root. So an annotation moved to any directory remains deletable. */
|
|
937
|
-
function deleteAnnotation(dataRoot: string, s: Store, annPath: string): void {
|
|
938
|
-
const segs = strToSegs(annPath);
|
|
939
|
-
if (!String(segs[segs.length - 1] ?? "").endsWith(".yamlover")) throw new Error("not an annotation file");
|
|
940
|
-
if (s.node(storePath(segs))?.format !== "x-yamlover-annotation") throw new Error("not an annotation node");
|
|
941
|
-
const root = path.resolve(dataRoot);
|
|
942
|
-
const file = path.resolve(dataRoot, ...segs.map(String));
|
|
943
|
-
if (!file.startsWith(root + path.sep)) throw new Error("outside the served root");
|
|
944
|
-
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) throw new Error("not an annotation file");
|
|
945
|
-
fs.rmSync(file, { force: true });
|
|
946
|
-
}
|
|
947
|
-
|
|
948
1069
|
// --------------------------------------------------------------------------- //
|
|
949
1070
|
// Paste / upload — drop a clipboard file OR plain text into the tree. A file: a directory target
|
|
950
1071
|
// takes it as a new child; a chapter target takes it into its owning directory and gains a `*…`
|
|
@@ -1061,7 +1182,7 @@ function pasteTextAsChapterFile(dataRoot: string, segs: Seg[], text: string): Re
|
|
|
1061
1182
|
const dir = path.resolve(dataRoot, ...dirSegs.map(String));
|
|
1062
1183
|
const title = titleFromText(text);
|
|
1063
1184
|
const final = uniqueName(dir, chapterFileName(title));
|
|
1064
|
-
const src = ["
|
|
1185
|
+
const src = ["!!<*::yamlover:$defs:chapter>", `title: ${JSON.stringify(title)}`, "chunks:", ...textChunkLines(text, 0), ""].join("\n");
|
|
1065
1186
|
writeInside(dataRoot, dir, final, Buffer.from(src, "utf8"));
|
|
1066
1187
|
return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
|
|
1067
1188
|
}
|
|
@@ -1181,7 +1302,7 @@ function pasteRichAsChapter(dataRoot: string, segs: Seg[], rich: Rich): Record<s
|
|
|
1181
1302
|
|
|
1182
1303
|
/** The whole .yamlover source of a new rich chapter (the tag, the title, chunks, children). */
|
|
1183
1304
|
function renderChapterSource(title: string, rich: Rich, pointerFor: (name: string, bytes: Buffer) => string): string {
|
|
1184
|
-
const lines = ["
|
|
1305
|
+
const lines = ["!!<*::yamlover:$defs:chapter>", `title: ${JSON.stringify(title)}`];
|
|
1185
1306
|
if (rich.chunks.length) lines.push("chunks:", ...rich.chunks.flatMap((c) => richItemLines(c, 0, pointerFor)));
|
|
1186
1307
|
if (rich.children.length) lines.push("children:", ...rich.children.flatMap((k) => richChildLines(k, 0, pointerFor)));
|
|
1187
1308
|
return lines.join("\n") + "\n";
|
package/src/server/node-kind.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { NodeRow, Store } from "../../../engine/ts/src/index.ts";
|
|
|
5
5
|
|
|
6
6
|
// One ordered container, classified for display: a pure-keyed mapping is `object`, a pure-keyless
|
|
7
7
|
// one `array`; a mapping mixing keyed + keyless OWNED entries is `mix`; a scalar/blob that ALSO
|
|
8
|
-
// carries OWNED fields is `omni` (the `!!mix`/`!!
|
|
8
|
+
// carries OWNED fields is `omni` (the `!!mix`/`!!var` shapes); plain scalars/blobs are
|
|
9
9
|
// `scalar`/`binary`.
|
|
10
10
|
export type Kind = "object" | "array" | "scalar" | "binary" | "omni" | "mix";
|
|
11
11
|
|
|
@@ -30,7 +30,7 @@ export function displayKind(s: Store, p: string, row: NodeRow): Kind {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Internal kind → the JSON-Schema-style `type:` name shown in the header/TOC and the schema view.
|
|
33
|
-
// The YAML-tag shapes `!!mix`/`!!
|
|
33
|
+
// The YAML-tag shapes `!!mix`/`!!var` get full-word schema names (cf. !!seq→array, !!map→object):
|
|
34
34
|
// `mix` → "mixed", `omni` → "variant". Scalars resolve to their JSON-ish primitive type.
|
|
35
35
|
export function typeName(s: Store, p: string, row: NodeRow): string {
|
|
36
36
|
const k = displayKind(s, p, row);
|
|
@@ -46,3 +46,16 @@ export function scalarType(v: unknown): string {
|
|
|
46
46
|
if (typeof v === "number") return Number.isInteger(v) ? "integer" : "number";
|
|
47
47
|
return "string";
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
/** The three TYPE FACETS the client dispatches on (TYPES.md §1): the scalar self-VALUE's type
|
|
51
|
+
* (`null|boolean|integer|number|string|binary`, or null when there is no value facet), and
|
|
52
|
+
* whether the node OWNS any KEYED / ORDINAL (keyless) elements. Reverse `~` members are excluded
|
|
53
|
+
* (ownedEntries) — a tagged node keeps its facets, so a renderer can tolerate the extra keys. */
|
|
54
|
+
export function facetsOf(s: Store, p: string, row: NodeRow): { valueType: string | null; hasKeyed: boolean; hasOrdinal: boolean } {
|
|
55
|
+
const ents = ownedEntries(s, p);
|
|
56
|
+
return {
|
|
57
|
+
valueType: row.type === "scalar" ? scalarType(row.value) : row.type === "blob" ? "binary" : null,
|
|
58
|
+
hasKeyed: ents.some((e) => e.label !== null),
|
|
59
|
+
hasOrdinal: ents.some((e) => e.label === null),
|
|
60
|
+
};
|
|
61
|
+
}
|