@hraness/sponge 0.2.4 → 0.2.5
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 +53 -0
- package/dist/migrations/005.d.ts +3 -0
- package/dist/migrations/005.js +19 -0
- package/dist/migrations/006.d.ts +3 -0
- package/dist/migrations/006.js +9 -0
- package/dist/migrations/007.d.ts +3 -0
- package/dist/migrations/007.js +10 -0
- package/dist/src/annotations/quote.d.ts +32 -0
- package/dist/src/annotations/quote.js +135 -0
- package/dist/src/annotations/store.d.ts +50 -0
- package/dist/src/annotations/store.js +89 -0
- package/dist/src/app/source.d.ts +2 -0
- package/dist/src/app/source.js +13 -17
- package/dist/src/cli/arguments.js +24 -7
- package/dist/src/cli/digest.d.ts +10 -0
- package/dist/src/cli/digest.js +63 -0
- package/dist/src/cli/import.d.ts +11 -0
- package/dist/src/cli/import.js +10 -0
- package/dist/src/cli/indexing.d.ts +3 -0
- package/dist/src/cli/indexing.js +5 -2
- package/dist/src/cli/main.js +94 -8
- package/dist/src/cli/runtimes.d.ts +10 -0
- package/dist/src/cli/runtimes.js +32 -0
- package/dist/src/digest/editorial.d.ts +53 -0
- package/dist/src/digest/editorial.js +81 -0
- package/dist/src/digest/endpoint.d.ts +20 -0
- package/dist/src/digest/endpoint.js +103 -0
- package/dist/src/digest/pipeline.d.ts +148 -0
- package/dist/src/digest/pipeline.js +410 -0
- package/dist/src/digest/schema.d.ts +85 -0
- package/dist/src/digest/schema.js +118 -0
- package/dist/src/digest/store.d.ts +57 -0
- package/dist/src/digest/store.js +77 -0
- package/dist/src/identity/query-policy.d.ts +21 -0
- package/dist/src/identity/query-policy.js +55 -0
- package/dist/src/identity/uri.d.ts +30 -0
- package/dist/src/identity/uri.js +94 -0
- package/dist/src/identity/visibility.d.ts +24 -0
- package/dist/src/identity/visibility.js +75 -0
- package/dist/src/importers/apply.d.ts +10 -0
- package/dist/src/importers/apply.js +25 -22
- package/dist/src/importers/jungle-digest.d.ts +97 -0
- package/dist/src/importers/jungle-digest.js +221 -0
- package/dist/src/importers/jungle-store.d.ts +45 -0
- package/dist/src/importers/jungle-store.js +95 -0
- package/dist/src/importers/jungle.d.ts +54 -0
- package/dist/src/importers/jungle.js +134 -0
- package/dist/src/indexing/index.d.ts +7 -0
- package/dist/src/indexing/index.js +31 -8
- package/dist/src/indexing/prune-run.d.ts +22 -0
- package/dist/src/indexing/prune-run.js +67 -0
- package/dist/src/indexing/prune.d.ts +30 -0
- package/dist/src/indexing/prune.js +95 -0
- package/dist/src/indexing/runner.js +5 -5
- package/dist/src/inference/embedding/index.d.ts +25 -0
- package/dist/src/inference/embedding/index.js +11 -0
- package/dist/src/models/artifacts.js +175 -33
- package/dist/src/portability/database.js +3 -0
- package/dist/src/publish/component.d.ts +8 -0
- package/dist/src/publish/component.js +114 -0
- package/dist/src/publish/feed.d.ts +24 -0
- package/dist/src/publish/feed.js +77 -0
- package/dist/src/publish/identity.d.ts +6 -0
- package/dist/src/publish/identity.js +68 -0
- package/dist/src/publish/index.d.ts +34 -0
- package/dist/src/publish/index.js +137 -0
- package/dist/src/publish/projection.d.ts +29 -0
- package/dist/src/publish/projection.js +43 -0
- package/dist/src/search/hybrid.js +2 -2
- package/dist/src/search/lexical.d.ts +11 -0
- package/dist/src/search/lexical.js +6 -0
- package/dist/src/search/query.d.ts +7 -3
- package/dist/src/search/query.js +52 -33
- package/dist/src/search/vector.d.ts +1 -1
- package/dist/src/search/vector.js +6 -5
- package/dist/src/store/changes.d.ts +42 -0
- package/dist/src/store/changes.js +178 -0
- package/dist/src/store/foundation.d.ts +7 -0
- package/dist/src/store/foundation.js +7 -0
- package/dist/src/store/index.d.ts +69 -0
- package/dist/src/store/index.js +134 -51
- package/dist/src/worker/runtime-gc.d.ts +30 -0
- package/dist/src/worker/runtime-gc.js +296 -0
- package/native/runtime-manifest.json +4 -4
- package/package.json +1 -1
|
@@ -27,7 +27,7 @@ async function promote(context, bundle, namespace, entry, sourceHash) {
|
|
|
27
27
|
throw Error('cancelled');
|
|
28
28
|
await checkpoint('promoting');
|
|
29
29
|
const title = selectedMetadata(bundle.metadata, 'title')?.value;
|
|
30
|
-
store.commitRevision({ ...attempt, itemId: source.itemId, bundleHash:
|
|
30
|
+
store.commitRevision({ ...attempt, itemId: source.itemId, bundleHash: importBundleHash(sourceHash), markdown: localized.markdown, title: typeof title === 'string' ? title : undefined, extractor: { name: 'sponge-wordcell-import', version: '1' }, status: bundle.status === 'partial' || localized.missingImages ? 'partial' : 'complete', metadata: [...bundle.metadata, ...Object.entries({ importNamespace: namespace, externalSourceId: entry.id, sourcePrivacy: entry.privacy }).map(([field, value]) => ({ field, value, source: bundle.kind === 'pdf' ? 'pdf' : 'html', observedAt: new Date().toISOString(), rule: 'sponge-import-v1' }))], assets, warnings: bundle.status === 'partial' || localized.missingImages ? ['partial'] : [], importedState: { namespace, sourceKey: entry.id, sourceHash, importerVersion: '1', notes: entry.notes, readingState: entry.readingState, privacy: entry.privacy } });
|
|
31
31
|
committed = true;
|
|
32
32
|
result = { ok: true, itemId: source.itemId };
|
|
33
33
|
}
|
|
@@ -39,6 +39,28 @@ async function promote(context, bundle, namespace, entry, sourceHash) {
|
|
|
39
39
|
store.settleAttempt(attempt);
|
|
40
40
|
return { ...result, cleanup: 'settled' };
|
|
41
41
|
}
|
|
42
|
+
/** One verified bundle: a receipt short-circuits, a dry run stops after verification, otherwise the capture is promoted through the queue. */
|
|
43
|
+
export async function importBundleEntry(store, root, namespace, entry, verify, dryRun, signal) {
|
|
44
|
+
const bundle = await inspectImportBundle(await importBundleRoot(root, entry), verify), sourceHash = hash(JSON.stringify([bundle.importHash, entry])), receipt = store.lookupImportReceipt(namespace, entry.id, sourceHash);
|
|
45
|
+
if (signal?.aborted)
|
|
46
|
+
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
47
|
+
const base = { sourceHash, historicalIntegrity: bundle.historicalIntegrity };
|
|
48
|
+
if (receipt)
|
|
49
|
+
return { ...base, status: 'duplicate', itemId: receipt.itemId };
|
|
50
|
+
if (dryRun)
|
|
51
|
+
return { ...base, status: 'ready' };
|
|
52
|
+
const submission = store.submitSource({ kind: bundle.kind, sourceKey: hash(JSON.stringify([namespace, entry.id])), locator: bundle.sourceUrl, realm: 'import:' + namespace, idempotencyKey: 'import:' + hash(JSON.stringify([namespace, entry.id, sourceHash])), intent: 'refresh' });
|
|
53
|
+
const job = store.listJobs().find(row => row.id === submission.jobId);
|
|
54
|
+
if (job && ['failed', 'needs-auth', 'needs-permission', 'unsupported'].includes(String(job.state)))
|
|
55
|
+
store.retryJob(submission.jobId, { automatic: false });
|
|
56
|
+
const results = await drainQueue(store, { jobId: submission.jobId, signal, handlers: { [bundle.kind]: context => promote(context, bundle, namespace, entry, sourceHash) } });
|
|
57
|
+
const result = results[0];
|
|
58
|
+
if (!result || !result.ok)
|
|
59
|
+
throw Object.assign(Error(result && !result.ok ? result.code : 'integrity'), { code: result && !result.ok ? result.code : 'integrity' });
|
|
60
|
+
return { ...base, status: 'imported', itemId: submission.itemId };
|
|
61
|
+
}
|
|
62
|
+
/** The revision a bundle import committed: content-addressed by its source hash. */
|
|
63
|
+
export const importBundleHash = (sourceHash) => hash(JSON.stringify(['wordcell-import-v1', sourceHash]));
|
|
42
64
|
/** Additive bounded batches. Every applied entry is reverified; source bundles are read-only. */
|
|
43
65
|
export async function importManifest(store, path, options) {
|
|
44
66
|
const { root, manifest, sha256 } = await readImportManifest(path), limit = options.limit ?? 20, offset = options.offset ?? 0;
|
|
@@ -50,27 +72,8 @@ export async function importManifest(store, path, options) {
|
|
|
50
72
|
if (options.signal?.aborted)
|
|
51
73
|
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
52
74
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
throw Object.assign(Error('cancelled'), { code: 'cancelled' });
|
|
56
|
-
const base = { id: entry.id, sourceHash, historicalIntegrity: bundle.historicalIntegrity, privacy: entry.privacy };
|
|
57
|
-
if (receipt) {
|
|
58
|
-
report.entries.push({ ...base, status: 'duplicate', itemId: receipt.itemId });
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (dryRun) {
|
|
62
|
-
report.entries.push({ ...base, status: 'ready' });
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const submission = store.submitSource({ kind: bundle.kind, sourceKey: hash(JSON.stringify([manifest.namespace, entry.id])), locator: bundle.sourceUrl, realm: 'import:' + manifest.namespace, idempotencyKey: 'import:' + hash(JSON.stringify([manifest.namespace, entry.id, sourceHash])), intent: 'refresh' });
|
|
66
|
-
const job = store.listJobs().find(row => row.id === submission.jobId);
|
|
67
|
-
if (job && ['failed', 'needs-auth', 'needs-permission', 'unsupported'].includes(String(job.state)))
|
|
68
|
-
store.retryJob(submission.jobId, { automatic: false });
|
|
69
|
-
const results = await drainQueue(store, { jobId: submission.jobId, signal: options.signal, handlers: { [bundle.kind]: context => promote(context, bundle, manifest.namespace, entry, sourceHash) } });
|
|
70
|
-
const result = results[0];
|
|
71
|
-
if (!result || !result.ok)
|
|
72
|
-
throw Object.assign(Error(result && !result.ok ? result.code : 'integrity'), { code: result && !result.ok ? result.code : 'integrity' });
|
|
73
|
-
report.entries.push({ ...base, status: 'imported', itemId: submission.itemId });
|
|
75
|
+
const result = await importBundleEntry(store, root, manifest.namespace, entry, verify, dryRun, options.signal), base = { id: entry.id, sourceHash: result.sourceHash, historicalIntegrity: result.historicalIntegrity, privacy: entry.privacy };
|
|
76
|
+
report.entries.push({ ...base, status: result.status, ...(result.itemId ? { itemId: result.itemId } : {}) });
|
|
74
77
|
}
|
|
75
78
|
catch (error) {
|
|
76
79
|
const code = errorCode(error);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { type DigestIssue, type DigestV1 } from '../digest/schema.js';
|
|
2
|
+
import { type Form, type QuoteMatch } from '../digest/pipeline.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pure conversion of one reviewed Jungle Reading digest note (`kb/notes/reading/<slug>.md`) into Digest v1. Jungle keeps
|
|
5
|
+
* quotes in their own section, so each quote is assigned to the idea it shares the most content words with.
|
|
6
|
+
* Nothing here reads files; callers pass the note and, when available, its capture Markdown.
|
|
7
|
+
*/
|
|
8
|
+
export declare const jungleTags: readonly ["agent-eng", "ai", "work", "business", "creative-coding", "finance", "music", "internet", "security", "history", "art", "hardware", "climate", "article", "interactive", "code", "x", "video"];
|
|
9
|
+
/** Tags that name how a source is consumed; Digest v1 carries that as `form`. */
|
|
10
|
+
export declare const jungleFormatTags: readonly ["article", "interactive", "x", "video"];
|
|
11
|
+
export declare class JungleDigestError extends Error {
|
|
12
|
+
readonly code: 'not-digest' | 'malformed' | 'no-capture';
|
|
13
|
+
constructor(code: 'not-digest' | 'malformed' | 'no-capture');
|
|
14
|
+
}
|
|
15
|
+
export interface JungleQuote {
|
|
16
|
+
text: string;
|
|
17
|
+
attribution: string;
|
|
18
|
+
speaker: string;
|
|
19
|
+
}
|
|
20
|
+
export interface JungleNote {
|
|
21
|
+
slug: string;
|
|
22
|
+
title: string;
|
|
23
|
+
status: string;
|
|
24
|
+
sourceUrl: string;
|
|
25
|
+
authors: string[];
|
|
26
|
+
tags: string[];
|
|
27
|
+
capture?: string;
|
|
28
|
+
gist: string;
|
|
29
|
+
ideas: {
|
|
30
|
+
claim: string;
|
|
31
|
+
detail: string;
|
|
32
|
+
}[];
|
|
33
|
+
quotes: JungleQuote[];
|
|
34
|
+
}
|
|
35
|
+
export type Lossy = 'context-trimmed' | 'quote-unassigned' | 'quote-missing' | 'idea-unquoted' | 'no-content-tag';
|
|
36
|
+
export interface JungleConversion {
|
|
37
|
+
note: JungleNote;
|
|
38
|
+
/** Every idea, including unquoted ones: the evaluation gold. */
|
|
39
|
+
gold: DigestV1;
|
|
40
|
+
/** Per Jungle quote: the gold idea it was assigned to, and how it located in the capture. */
|
|
41
|
+
quotes: {
|
|
42
|
+
idea: number | null;
|
|
43
|
+
match?: QuoteMatch;
|
|
44
|
+
}[];
|
|
45
|
+
lossy: Lossy[];
|
|
46
|
+
/** checkDigest issues of the gold against the capture; empty without a capture. */
|
|
47
|
+
issues: DigestIssue[];
|
|
48
|
+
/** The gold with unlocated quotes and then unquoted ideas dropped, when that still passes every v1 rule. */
|
|
49
|
+
importable?: DigestV1;
|
|
50
|
+
/** Gold passes every v1 rule against the capture with nothing dropped or trimmed. `kind` is never gold: Jungle has no kinds. */
|
|
51
|
+
clean: boolean;
|
|
52
|
+
}
|
|
53
|
+
export type Yaml = string | boolean | Yaml[] | {
|
|
54
|
+
[k: string]: Yaml;
|
|
55
|
+
};
|
|
56
|
+
/** Capture Markdown without its YAML frontmatter: what a Sponge revision of the same page would hold. */
|
|
57
|
+
export declare function captureBody(capture: string): string;
|
|
58
|
+
export declare function parseJungleNote(markdown: string): JungleNote;
|
|
59
|
+
/**
|
|
60
|
+
* One Jungle Reading note of any form (digest, social, note, conversation), kept whole: the exact Markdown and its hash,
|
|
61
|
+
* the parsed frontmatter, and the gist, ideas and quotes where the form has them. Nothing is trimmed to Digest v1 limits.
|
|
62
|
+
* `capture` is the evidence the note is about: the first `synthesizes` relation, else the first `evidenced-by`.
|
|
63
|
+
*/
|
|
64
|
+
export interface JungleEditorial {
|
|
65
|
+
schemaVersion: 1;
|
|
66
|
+
source: 'jungle-reading';
|
|
67
|
+
form: string;
|
|
68
|
+
slug: string;
|
|
69
|
+
status: string;
|
|
70
|
+
title: string;
|
|
71
|
+
sourceUrl: string;
|
|
72
|
+
capture: string;
|
|
73
|
+
frontmatter: Yaml;
|
|
74
|
+
gist?: string;
|
|
75
|
+
ideas?: {
|
|
76
|
+
claim: string;
|
|
77
|
+
detail: string;
|
|
78
|
+
}[];
|
|
79
|
+
quotes?: JungleQuote[];
|
|
80
|
+
markdownSha256: string;
|
|
81
|
+
markdown: string;
|
|
82
|
+
}
|
|
83
|
+
export declare function parseJungleEditorial(markdown: string): JungleEditorial;
|
|
84
|
+
/** Content-word stems: lowercase, stopwords out, a crude plural/tense strip. Shared by idea assignment and lexical coverage. */
|
|
85
|
+
export declare function contentTerms(text: string): Set<string>;
|
|
86
|
+
export declare function termSimilarity(a: Set<string>, b: Set<string>): number;
|
|
87
|
+
/** Best-first assignment: every idea gets at most one quote before any gets a second; zero-overlap pairs are never made. */
|
|
88
|
+
export declare function assignQuotes(ideas: {
|
|
89
|
+
claim: string;
|
|
90
|
+
detail: string;
|
|
91
|
+
}[], quotes: {
|
|
92
|
+
text: string;
|
|
93
|
+
}[]): (number | null)[];
|
|
94
|
+
export declare const firstSentence: (text: string) => readonly [string, string];
|
|
95
|
+
export declare function formOf(note: JungleNote): Form;
|
|
96
|
+
/** Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), quotes are located and the gold is checked. */
|
|
97
|
+
export declare function convertJungleDigest(noteMarkdown: string, capture?: string): JungleConversion;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { foldText, locateAll, words } from '../annotations/quote.js';
|
|
3
|
+
import { checkDigest, digestLimits as L } from '../digest/schema.js';
|
|
4
|
+
import { minQuoteWords, snapQuote } from '../digest/pipeline.js';
|
|
5
|
+
/**
|
|
6
|
+
* Pure conversion of one reviewed Jungle Reading digest note (`kb/notes/reading/<slug>.md`) into Digest v1. Jungle keeps
|
|
7
|
+
* quotes in their own section, so each quote is assigned to the idea it shares the most content words with.
|
|
8
|
+
* Nothing here reads files; callers pass the note and, when available, its capture Markdown.
|
|
9
|
+
*/
|
|
10
|
+
export const jungleTags = ['agent-eng', 'ai', 'work', 'business', 'creative-coding', 'finance', 'music', 'internet', 'security', 'history', 'art', 'hardware', 'climate', 'article', 'interactive', 'code', 'x', 'video'];
|
|
11
|
+
/** Tags that name how a source is consumed; Digest v1 carries that as `form`. */
|
|
12
|
+
export const jungleFormatTags = ['article', 'interactive', 'x', 'video'];
|
|
13
|
+
export class JungleDigestError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code) {
|
|
16
|
+
super(code);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = 'JungleDigestError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const fail = (code = 'malformed') => { throw new JungleDigestError(code); };
|
|
22
|
+
/** The frontmatter subset Jungle writes: block maps and lists of scalars, quoted or plain. */
|
|
23
|
+
function parseYaml(lines) {
|
|
24
|
+
let i = 0;
|
|
25
|
+
const indent = (l) => l.length - l.trimStart().length;
|
|
26
|
+
const scalar = (v) => { v = v.trim(); if (v.startsWith('"')) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(v);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return fail();
|
|
32
|
+
}
|
|
33
|
+
} if (v.startsWith("'"))
|
|
34
|
+
return v.endsWith("'") && v.length > 1 ? v.slice(1, -1).replace(/''/g, "'") : fail(); return v === 'true' ? true : v === 'false' ? false : v; };
|
|
35
|
+
const block = (at) => {
|
|
36
|
+
if (lines[i].trimStart().startsWith('- ')) {
|
|
37
|
+
const list = [];
|
|
38
|
+
while (i < lines.length && indent(lines[i]) === at && lines[i].trimStart().startsWith('- ')) {
|
|
39
|
+
const rest = lines[i].trimStart().slice(2);
|
|
40
|
+
if (/^[\w-]+:(\s|$)/.test(rest)) {
|
|
41
|
+
lines[i] = ' '.repeat(at + 2) + rest;
|
|
42
|
+
list.push(map(at + 2));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
list.push(scalar(rest));
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return list;
|
|
50
|
+
}
|
|
51
|
+
return map(at);
|
|
52
|
+
};
|
|
53
|
+
const map = (at) => {
|
|
54
|
+
const out = {};
|
|
55
|
+
while (i < lines.length && indent(lines[i]) === at && !lines[i].trimStart().startsWith('- ')) {
|
|
56
|
+
const m = /^([\w-]+):(?:\s+(.*))?$/.exec(lines[i].trim()) ?? fail();
|
|
57
|
+
i++;
|
|
58
|
+
if (m[2] !== undefined && m[2] !== '')
|
|
59
|
+
out[m[1]] = scalar(m[2]);
|
|
60
|
+
else if (i < lines.length && (indent(lines[i]) > at || indent(lines[i]) === at && lines[i].trimStart().startsWith('- ')))
|
|
61
|
+
out[m[1]] = block(indent(lines[i]));
|
|
62
|
+
else
|
|
63
|
+
out[m[1]] = '';
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
};
|
|
67
|
+
const value = lines.length ? map(0) : {};
|
|
68
|
+
return i === lines.length ? value : fail();
|
|
69
|
+
}
|
|
70
|
+
const object = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
71
|
+
const strings = (v) => Array.isArray(v) && v.every(x => typeof x === 'string') ? v : fail();
|
|
72
|
+
/** Split a Markdown file into its frontmatter lines and body. */
|
|
73
|
+
function frontmatter(markdown) {
|
|
74
|
+
const m = /^---\n([^]*?)\n---(?:\n|$)/.exec(markdown.replace(/\r\n/g, '\n'));
|
|
75
|
+
return m ? { head: m[1].split('\n').filter(l => l.trim() && !l.trimStart().startsWith('#')), body: markdown.replace(/\r\n/g, '\n').slice(m[0].length) } : { head: [], body: markdown };
|
|
76
|
+
}
|
|
77
|
+
/** Capture Markdown without its YAML frontmatter: what a Sponge revision of the same page would hold. */
|
|
78
|
+
export function captureBody(capture) { return frontmatter(capture).body; }
|
|
79
|
+
/** `## Heading` sections of a note body, trimmed. */
|
|
80
|
+
function sectionsOf(body) {
|
|
81
|
+
const sections = new Map();
|
|
82
|
+
for (const part of body.split(/^## /m).slice(1)) {
|
|
83
|
+
const nl = part.indexOf('\n');
|
|
84
|
+
sections.set(part.slice(0, nl < 0 ? undefined : nl).trim(), nl < 0 ? '' : part.slice(nl + 1).trim());
|
|
85
|
+
}
|
|
86
|
+
return sections;
|
|
87
|
+
}
|
|
88
|
+
/** `- **Claim.** detail` bullets: Jungle's Ideas, and a conversation's Debate. */
|
|
89
|
+
const ideaList = (text) => text.split('\n').filter(l => l.startsWith('- ')).map(l => { const m = /^- \*\*(.+?)\*\*\s*(.*)$/.exec(l.trim()) ?? fail(); return { claim: m[1].trim(), detail: m[2].trim() }; });
|
|
90
|
+
/** Blockquotes ending `> Attribution: Speaker, context`. */
|
|
91
|
+
const quoteList = (text) => text.split(/\n\s*\n/).filter(b => b.trim()).map(b => {
|
|
92
|
+
const lines = b.split('\n').map(l => /^>\s?(.*)$/.exec(l)?.[1] ?? fail()), at = lines.findIndex(l => l.startsWith('Attribution:'));
|
|
93
|
+
if (at < 1)
|
|
94
|
+
fail();
|
|
95
|
+
const text = lines.slice(0, at).join(' ').replace(/\s+/g, ' ').trim(), attribution = lines[at].slice('Attribution:'.length).trim();
|
|
96
|
+
return { text, attribution, speaker: attribution.split(/,|;|\s\(|\s—\s|\s-\s/)[0].trim() };
|
|
97
|
+
});
|
|
98
|
+
export function parseJungleNote(markdown) {
|
|
99
|
+
if (typeof markdown !== 'string' || markdown.length > 4 * 1024 * 1024)
|
|
100
|
+
fail();
|
|
101
|
+
const { head, body } = frontmatter(markdown), meta = parseYaml(head);
|
|
102
|
+
if (!object(meta) || !object(meta.reading))
|
|
103
|
+
throw new JungleDigestError('not-digest');
|
|
104
|
+
const reading = meta.reading;
|
|
105
|
+
if (reading.form !== 'digest')
|
|
106
|
+
fail('not-digest');
|
|
107
|
+
const sections = sectionsOf(body);
|
|
108
|
+
const gist = (sections.get('Gist') ?? fail()).replace(/\s+/g, ' ').trim();
|
|
109
|
+
const ideas = ideaList(sections.get('Ideas') ?? fail());
|
|
110
|
+
const quotes = quoteList(sections.get('Quotes') ?? '');
|
|
111
|
+
if (!gist || !ideas.length || quotes.some(q => !q.text || !q.speaker))
|
|
112
|
+
fail();
|
|
113
|
+
const authors = Array.isArray(reading.authors) ? reading.authors.map(a => object(a) && typeof a.name === 'string' ? a.name : fail()) : [];
|
|
114
|
+
const relations = object(meta.relations) ? meta.relations : {}, synth = Array.isArray(relations.synthesizes) ? strings(relations.synthesizes) : [];
|
|
115
|
+
return { slug: typeof reading.slug === 'string' ? reading.slug : fail(), title: typeof meta.title === 'string' ? meta.title : '', status: typeof reading.status === 'string' ? reading.status : '', sourceUrl: typeof reading.source_url === 'string' ? reading.source_url : '', authors, tags: meta.tags === undefined ? [] : strings(meta.tags), ...(synth[0] ? { capture: synth[0] } : {}), gist, ideas, quotes };
|
|
116
|
+
}
|
|
117
|
+
export function parseJungleEditorial(markdown) {
|
|
118
|
+
if (typeof markdown !== 'string' || markdown.length > 4 * 1024 * 1024)
|
|
119
|
+
fail();
|
|
120
|
+
const { head, body } = frontmatter(markdown), meta = parseYaml(head);
|
|
121
|
+
if (!object(meta) || !object(meta.reading))
|
|
122
|
+
throw new JungleDigestError('not-digest');
|
|
123
|
+
const reading = meta.reading, relations = object(meta.relations) ? meta.relations : {};
|
|
124
|
+
const form = reading.form, slug = reading.slug;
|
|
125
|
+
if (typeof form !== 'string' || !form || typeof slug !== 'string' || !/^[a-z0-9][a-z0-9-]{0,199}$/.test(slug))
|
|
126
|
+
return fail();
|
|
127
|
+
const capture = [relations.synthesizes, relations['evidenced-by']].flatMap(v => Array.isArray(v) ? strings(v) : [])[0] ?? fail('no-capture');
|
|
128
|
+
// A digest must parse strictly; the other forms keep whatever structure they have, and the Markdown keeps the rest.
|
|
129
|
+
const sections = sectionsOf(body), loose = (f) => { if (form === 'digest')
|
|
130
|
+
return f(); try {
|
|
131
|
+
return f();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
} };
|
|
136
|
+
const gistText = sections.get('Gist') ?? sections.get('Summary') ?? (typeof reading.summary === 'string' ? reading.summary : undefined), ideaText = sections.get('Ideas') ?? sections.get('Debate'), quoteText = sections.get('Quotes');
|
|
137
|
+
if (form === 'digest')
|
|
138
|
+
parseJungleNote(markdown);
|
|
139
|
+
const gist = gistText?.replace(/\s+/g, ' ').trim(), ideas = ideaText === undefined ? undefined : loose(() => ideaList(ideaText)), quotes = quoteText === undefined ? undefined : loose(() => quoteList(quoteText));
|
|
140
|
+
return { schemaVersion: 1, source: 'jungle-reading', form, slug, status: typeof reading.status === 'string' ? reading.status : '', title: typeof meta.title === 'string' ? meta.title : '', sourceUrl: typeof reading.source_url === 'string' ? reading.source_url : '', capture, frontmatter: meta, ...(gist ? { gist } : {}), ...(ideas?.length ? { ideas } : {}), ...(quotes?.length && quotes.every(q => q.text && q.speaker) ? { quotes } : {}), markdownSha256: createHash('sha256').update(markdown).digest('hex'), markdown };
|
|
141
|
+
}
|
|
142
|
+
const stop = new Set('the a an and or but of to in on for with by from at as is are was were be been it its this that these those not no than then so such into about over under more most less can could would should will just also only very their there they them we our you your he she his her i my me what which who how when where why do does did has have had'.split(' '));
|
|
143
|
+
/** Content-word stems: lowercase, stopwords out, a crude plural/tense strip. Shared by idea assignment and lexical coverage. */
|
|
144
|
+
export function contentTerms(text) {
|
|
145
|
+
return new Set(foldText(text).toLowerCase().split(/[^a-z0-9]+/).filter(w => w.length > 2 && !stop.has(w)).map(w => w.replace(/(?:ing|ed|es|s)$/, '')).filter(w => w.length > 2));
|
|
146
|
+
}
|
|
147
|
+
export function termSimilarity(a, b) { if (!a.size || !b.size)
|
|
148
|
+
return 0; let n = 0; for (const t of a)
|
|
149
|
+
if (b.has(t))
|
|
150
|
+
n++; return n / Math.sqrt(a.size * b.size); }
|
|
151
|
+
/** Best-first assignment: every idea gets at most one quote before any gets a second; zero-overlap pairs are never made. */
|
|
152
|
+
export function assignQuotes(ideas, quotes) { return assign(ideas, quotes); }
|
|
153
|
+
function assign(ideas, quotes) {
|
|
154
|
+
const ideaTerms = ideas.map(i => contentTerms(`${i.claim} ${i.detail}`)), pairs = [];
|
|
155
|
+
quotes.forEach((q, qi) => { const t = contentTerms(q.text); ideaTerms.forEach((it, ii) => { const s = termSimilarity(t, it); if (s > 0)
|
|
156
|
+
pairs.push({ q: qi, i: ii, s }); }); });
|
|
157
|
+
pairs.sort((a, b) => b.s - a.s || Math.abs(a.q - a.i) - Math.abs(b.q - b.i) || a.q - b.q);
|
|
158
|
+
const out = quotes.map(() => null), load = ideas.map(() => 0);
|
|
159
|
+
for (const cap of [1, L.maxQuotesPerIdea])
|
|
160
|
+
for (const p of pairs)
|
|
161
|
+
if (out[p.q] === null && load[p.i] < cap) {
|
|
162
|
+
out[p.q] = p.i;
|
|
163
|
+
load[p.i]++;
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
export const firstSentence = (text) => { const m = /^(.+?[.!?]["”’)]*)\s+(?=["“(]?[A-Z0-9])/.exec(text); return m ? [m[1], text.slice(m[0].length)] : [text, '']; };
|
|
168
|
+
export function formOf(note) {
|
|
169
|
+
if (note.tags.includes('video'))
|
|
170
|
+
return 'video';
|
|
171
|
+
if (note.tags.includes('x'))
|
|
172
|
+
return 'thread';
|
|
173
|
+
return /\.pdf(?:$|[?#])|arxiv\.org\/pdf\//i.test(note.sourceUrl) ? 'pdf' : 'article';
|
|
174
|
+
}
|
|
175
|
+
/** Convert one Jungle digest note. With `capture` (the capture Markdown, frontmatter allowed), quotes are located and the gold is checked. */
|
|
176
|
+
export function convertJungleDigest(noteMarkdown, capture) {
|
|
177
|
+
const note = parseJungleNote(noteMarkdown), lossy = new Set(), source = capture === undefined ? undefined : captureBody(capture);
|
|
178
|
+
const [lead, rest] = firstSentence(note.gist);
|
|
179
|
+
let context = '';
|
|
180
|
+
for (let r = rest; r;) {
|
|
181
|
+
const [s, next] = firstSentence(r);
|
|
182
|
+
if (words(context ? `${context} ${s}` : s) > L.contextWords) {
|
|
183
|
+
lossy.add('context-trimmed');
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
context = context ? `${context} ${s}` : s;
|
|
187
|
+
r = next;
|
|
188
|
+
}
|
|
189
|
+
const assigned = assign(note.ideas, note.quotes), authors = new Set(note.authors.map(a => foldText(a).toLowerCase())), form = formOf(note);
|
|
190
|
+
const located = note.quotes.map(q => source === undefined ? undefined : snapQuote(source, q.text));
|
|
191
|
+
const ideas = note.ideas.map((idea, i) => {
|
|
192
|
+
const mine = note.quotes.map((q, k) => ({ q, k })).filter(x => assigned[x.k] === i);
|
|
193
|
+
const other = mine.find(x => !authors.has(foldText(x.q.speaker).toLowerCase()));
|
|
194
|
+
// A non-author in a talk is a speaker; in writing, the author reports them.
|
|
195
|
+
const voice = other ? { type: form === 'video' ? 'speaker' : 'reported', name: other.q.speaker } : { type: 'author' };
|
|
196
|
+
const quotes = mine.map(x => ({ text: located[x.k]?.text ?? x.q.text, speaker: x.q.speaker }));
|
|
197
|
+
return { claim: idea.claim, detail: idea.detail, kind: 'argument', voice, quotes };
|
|
198
|
+
});
|
|
199
|
+
const content = note.tags.filter(t => t !== 'reading' && !jungleFormatTags.includes(t)), tags = (content.length ? content : note.tags.filter(t => t !== 'reading')).slice(0, L.maxTags);
|
|
200
|
+
if (!content.length)
|
|
201
|
+
lossy.add('no-content-tag');
|
|
202
|
+
if (assigned.some(a => a === null))
|
|
203
|
+
lossy.add('quote-unassigned');
|
|
204
|
+
if (located.some(l => l?.match === 'missing'))
|
|
205
|
+
lossy.add('quote-missing');
|
|
206
|
+
if (ideas.some(i => !i.quotes.length))
|
|
207
|
+
lossy.add('idea-unquoted');
|
|
208
|
+
const gold = { schemaVersion: 1, form, gist: { lead, ...(context ? { context } : {}) }, ideas, tags };
|
|
209
|
+
const options = { tags: jungleTags };
|
|
210
|
+
const issues = source === undefined ? [] : checkDigest(gold, source, options).issues;
|
|
211
|
+
let importable;
|
|
212
|
+
if (source !== undefined) {
|
|
213
|
+
const kept = note.quotes.map((_, k) => assigned[k] !== null && located[k]?.match !== 'missing');
|
|
214
|
+
// Import keeps Sponge's ordering rule: ideas by their earliest quote in the capture, quotes of at least minQuoteWords.
|
|
215
|
+
const at = (t) => locateAll(source, t)[0]?.start ?? Number.MAX_SAFE_INTEGER;
|
|
216
|
+
const trimmed = { ...gold, ideas: note.ideas.map((_, i) => ({ ...ideas[i], quotes: note.quotes.flatMap((__, k) => assigned[k] === i && kept[k] && words(located[k].text) >= minQuoteWords ? [{ text: located[k].text, speaker: note.quotes[k].speaker }] : []).sort((a, b) => at(a.text) - at(b.text)) })).filter(i => i.quotes.length).sort((a, b) => at(a.quotes[0].text) - at(b.quotes[0].text)) };
|
|
217
|
+
if (checkDigest(trimmed, source, options).ok)
|
|
218
|
+
importable = trimmed;
|
|
219
|
+
}
|
|
220
|
+
return { note, gold, quotes: note.quotes.map((_, k) => ({ idea: assigned[k] ?? null, ...(located[k] ? { match: located[k].match } : {}) })), lossy: [...lossy], issues, ...(importable ? { importable } : {}), clean: source !== undefined && issues.length === 0 && lossy.size === 0 };
|
|
221
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type DigestV1 } from '../digest/schema.js';
|
|
3
|
+
import { type VisibilityState } from '../identity/visibility.js';
|
|
4
|
+
import { type JungleEditorial } from './jungle-digest.js';
|
|
5
|
+
/**
|
|
6
|
+
* The library side of `sponge import jungle`: one Jungle Reading note onto the item holding its capture.
|
|
7
|
+
* `planJungleNote` only reads; `applyJungleNote` runs the same plan inside the caller's transaction, through the logged paths.
|
|
8
|
+
*/
|
|
9
|
+
export declare const jungleNamespace = "jungle", jungleRealm = "import:jungle", jungleProducer: {
|
|
10
|
+
readonly importer: "sponge-jungle-reading";
|
|
11
|
+
readonly version: "1";
|
|
12
|
+
};
|
|
13
|
+
export type EditorialAction = 'new' | 'unchanged' | 'user-review-wins';
|
|
14
|
+
export type DigestAction = 'new' | 'unchanged' | 'not-convertible' | 'not-digest' | 'user-review-wins' | 'user-digest-wins';
|
|
15
|
+
export type VisibilityAction = 'set-public' | 'set-private' | 'restore-default' | 'unchanged' | 'kept-user-edit' | 'guarded' | 'default-policy';
|
|
16
|
+
export interface JungleNoteInput {
|
|
17
|
+
itemId: string;
|
|
18
|
+
revisionId: string;
|
|
19
|
+
editorial: JungleEditorial;
|
|
20
|
+
v1?: DigestV1;
|
|
21
|
+
}
|
|
22
|
+
export interface JunglePlan {
|
|
23
|
+
editorialId: string;
|
|
24
|
+
editorial: EditorialAction;
|
|
25
|
+
v1Id?: string;
|
|
26
|
+
v1: DigestAction;
|
|
27
|
+
visibility: VisibilityAction;
|
|
28
|
+
}
|
|
29
|
+
/** What Jungle's `reading.status` asks for: `published` is Jungle's explicit public approval, `draft` stays private. */
|
|
30
|
+
export declare function desiredVisibility(status: string): 'public' | 'private' | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Jungle's decision becomes an explicit override only while nobody else has set one: once the latest override was not
|
|
33
|
+
* written by this importer, the user's choice wins. A public decision never lifts the capability-URL or local-file guards.
|
|
34
|
+
*/
|
|
35
|
+
export declare function visibilityAction(state: Pick<VisibilityState, 'access' | 'default' | 'override'>, status: string, userOverride: boolean): {
|
|
36
|
+
action: VisibilityAction;
|
|
37
|
+
target?: 'public' | 'private' | null;
|
|
38
|
+
};
|
|
39
|
+
export declare function planJungleNote(db: DatabaseSync, input: JungleNoteInput): JunglePlan;
|
|
40
|
+
/**
|
|
41
|
+
* Apply one note; the caller holds the write transaction. A new editorial object is accepted through `review.note` unless
|
|
42
|
+
* the user already rejected or cleared an earlier one; a clean Digest v1 is stored and accepted per field, linked to the
|
|
43
|
+
* editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
|
|
44
|
+
*/
|
|
45
|
+
export declare function applyJungleNote(db: DatabaseSync, input: JungleNoteInput): JunglePlan;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { canonicalDigest, derivationId, putDigest, reviewDigest } from '../digest/store.js';
|
|
2
|
+
import { canonicalEditorial, putEditorial, reviewEditorial } from '../digest/editorial.js';
|
|
3
|
+
import { parseDigest } from '../digest/schema.js';
|
|
4
|
+
import { visibility as visibilityState, setVisibility } from '../identity/visibility.js';
|
|
5
|
+
import { digest } from '../store/files.js';
|
|
6
|
+
import { jungleTags } from './jungle-digest.js';
|
|
7
|
+
/**
|
|
8
|
+
* The library side of `sponge import jungle`: one Jungle Reading note onto the item holding its capture.
|
|
9
|
+
* `planJungleNote` only reads; `applyJungleNote` runs the same plan inside the caller's transaction, through the logged paths.
|
|
10
|
+
*/
|
|
11
|
+
export const jungleNamespace = 'jungle', jungleRealm = 'import:jungle', jungleProducer = { importer: 'sponge-jungle-reading', version: '1' };
|
|
12
|
+
/** Receipts of the importer's own visibility writes (local, like every import receipt), so a later user override is recognisable. */
|
|
13
|
+
const visibilityReceipts = 'jungle-visibility';
|
|
14
|
+
/** What Jungle's `reading.status` asks for: `published` is Jungle's explicit public approval, `draft` stays private. */
|
|
15
|
+
export function desiredVisibility(status) { return status === 'published' ? 'public' : status === 'draft' ? 'private' : undefined; }
|
|
16
|
+
/**
|
|
17
|
+
* Jungle's decision becomes an explicit override only while nobody else has set one: once the latest override was not
|
|
18
|
+
* written by this importer, the user's choice wins. A public decision never lifts the capability-URL or local-file guards.
|
|
19
|
+
*/
|
|
20
|
+
export function visibilityAction(state, status, userOverride) {
|
|
21
|
+
const desired = desiredVisibility(status);
|
|
22
|
+
if (!desired)
|
|
23
|
+
return { action: 'default-policy' };
|
|
24
|
+
if (userOverride)
|
|
25
|
+
return { action: 'kept-user-edit' };
|
|
26
|
+
if (desired === 'public' && state.access !== 'imported' && state.access !== 'public')
|
|
27
|
+
return { action: 'guarded' };
|
|
28
|
+
const target = desired === state.default ? null : desired;
|
|
29
|
+
if ((state.override ?? null) === target)
|
|
30
|
+
return { action: 'unchanged' };
|
|
31
|
+
return { action: target === null ? 'restore-default' : target === 'public' ? 'set-public' : 'set-private', target };
|
|
32
|
+
}
|
|
33
|
+
const allAccepted = (o) => ({ gist: 'accepted', ideas: o.ideas.map(() => 'accepted'), quotes: o.ideas.map(i => i.quotes.map(() => 'accepted')) });
|
|
34
|
+
const imported = (r) => { try {
|
|
35
|
+
return JSON.parse(String(r.producer_json))?.importer === jungleProducer.importer;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
} };
|
|
40
|
+
const latestVisibility = (db, itemId) => db.prepare("SELECT id FROM changes WHERE entity='item' AND entity_id=? AND field='visibility' ORDER BY seq DESC LIMIT 1").get(itemId);
|
|
41
|
+
/** The latest logged visibility write was not this importer's: the user chose, even when the choice was `default`. */
|
|
42
|
+
function userOverride(db, itemId) {
|
|
43
|
+
const latest = latestVisibility(db, itemId);
|
|
44
|
+
if (!latest)
|
|
45
|
+
return (db.prepare('SELECT visibility FROM items WHERE id=?').get(itemId)?.visibility ?? null) !== null;
|
|
46
|
+
return !db.prepare('SELECT 1 FROM import_receipts WHERE namespace=? AND source_key=? AND source_hash=?').get(visibilityReceipts, itemId, digest(String(latest.id)));
|
|
47
|
+
}
|
|
48
|
+
export function planJungleNote(db, input) {
|
|
49
|
+
const { itemId, revisionId, editorial } = input, editorialId = derivationId(revisionId, 'editorial', editorial.markdownSha256, canonicalEditorial(editorial));
|
|
50
|
+
const editorials = db.prepare("SELECT id,review_json FROM derivations WHERE item_id=? AND kind='editorial'").all(itemId);
|
|
51
|
+
const editorialAction = editorials.some(r => r.id === editorialId) ? 'unchanged' : editorials.some(r => JSON.parse(String(r.review_json)).note !== 'accepted') ? 'user-review-wins' : 'new';
|
|
52
|
+
let v1 = 'not-digest', v1Id;
|
|
53
|
+
if (editorial.form === 'digest' && !input.v1)
|
|
54
|
+
v1 = 'not-convertible';
|
|
55
|
+
else if (input.v1) {
|
|
56
|
+
const digests = db.prepare("SELECT id,producer_json,output_json,review_json FROM derivations WHERE item_id=? AND kind='digest'").all(itemId), parsed = parseDigest(input.v1).digest;
|
|
57
|
+
v1Id = parsed && derivationId(revisionId, 'digest', editorialId, canonicalDigest(parsed));
|
|
58
|
+
if (!parsed)
|
|
59
|
+
v1 = 'not-convertible';
|
|
60
|
+
else if (digests.some(r => r.id === v1Id))
|
|
61
|
+
v1 = 'unchanged';
|
|
62
|
+
else if (digests.some(r => !imported(r)))
|
|
63
|
+
v1 = 'user-digest-wins';
|
|
64
|
+
else if (digests.some(r => JSON.stringify(JSON.parse(String(r.review_json))) !== JSON.stringify(allAccepted(JSON.parse(String(r.output_json))))))
|
|
65
|
+
v1 = 'user-review-wins';
|
|
66
|
+
else
|
|
67
|
+
v1 = editorialAction === 'user-review-wins' ? 'user-review-wins' : 'new';
|
|
68
|
+
}
|
|
69
|
+
const visibility = visibilityAction(visibilityState(db, itemId), editorial.status, userOverride(db, itemId)).action;
|
|
70
|
+
return { editorialId, editorial: editorialAction, ...(v1Id ? { v1Id } : {}), v1, visibility };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Apply one note; the caller holds the write transaction. A new editorial object is accepted through `review.note` unless
|
|
74
|
+
* the user already rejected or cleared an earlier one; a clean Digest v1 is stored and accepted per field, linked to the
|
|
75
|
+
* editorial object by its input hash, unless the user owns a digest of this item or changed an imported digest's review.
|
|
76
|
+
*/
|
|
77
|
+
export function applyJungleNote(db, input) {
|
|
78
|
+
const plan = planJungleNote(db, input), producer = { ...jungleProducer, note: input.editorial.slug };
|
|
79
|
+
if (plan.editorial !== 'unchanged') {
|
|
80
|
+
putEditorial(db, { itemId: input.itemId, revisionId: input.revisionId, producer, output: input.editorial });
|
|
81
|
+
if (plan.editorial === 'new')
|
|
82
|
+
reviewEditorial(db, plan.editorialId, 'accepted');
|
|
83
|
+
}
|
|
84
|
+
if (plan.v1 === 'new') {
|
|
85
|
+
const stored = putDigest(db, { itemId: input.itemId, revisionId: input.revisionId, producer: { ...producer, editorial: plan.editorialId }, inputHash: plan.editorialId, output: input.v1, options: { tags: jungleTags } });
|
|
86
|
+
reviewDigest(db, stored.id, allAccepted(input.v1));
|
|
87
|
+
}
|
|
88
|
+
const action = visibilityAction(visibilityState(db, input.itemId), input.editorial.status, userOverride(db, input.itemId));
|
|
89
|
+
if (action.target !== undefined) {
|
|
90
|
+
setVisibility(db, input.itemId, action.target);
|
|
91
|
+
const latest = latestVisibility(db, input.itemId);
|
|
92
|
+
db.prepare('INSERT OR IGNORE INTO import_receipts VALUES(?,?,?,?,?,?)').run(visibilityReceipts, input.itemId, digest(String(latest.id)), jungleProducer.version, input.itemId, 'visibility');
|
|
93
|
+
}
|
|
94
|
+
return plan;
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { LibraryStore } from '../store/index.js';
|
|
2
|
+
import { type DigestV1 } from '../digest/schema.js';
|
|
3
|
+
import { type JungleEditorial } from './jungle-digest.js';
|
|
4
|
+
import { type DigestAction, type EditorialAction, type VisibilityAction } from './jungle-store.js';
|
|
5
|
+
import type { BundleVerifier } from './inspect.js';
|
|
6
|
+
/**
|
|
7
|
+
* `sponge import jungle DIR`: every Jungle Reading note (`kb/notes/reading/<slug>.md`) onto the item holding its capture.
|
|
8
|
+
* The capture goes through the verified bundle import (always as a private association); the note becomes an accepted
|
|
9
|
+
* editorial derivation, plus an accepted Digest v1 when it converts with nothing dropped; `reading.status` becomes the
|
|
10
|
+
* item's visibility. Jungle is only read. A dry run reads the notes, captures and library and writes nothing.
|
|
11
|
+
*/
|
|
12
|
+
export interface JungleImportOptions {
|
|
13
|
+
verify?: BundleVerifier;
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
limit?: number;
|
|
16
|
+
offset?: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
export type JungleSkip = 'not-reading-note' | 'malformed-note' | 'no-capture' | 'capture-missing' | 'item-deleted' | 'capture-unsupported' | 'capture-failed';
|
|
20
|
+
export interface JungleEntry {
|
|
21
|
+
slug: string;
|
|
22
|
+
form?: string;
|
|
23
|
+
status?: string;
|
|
24
|
+
item?: 'new' | 'existing';
|
|
25
|
+
itemId?: string;
|
|
26
|
+
editorial?: EditorialAction;
|
|
27
|
+
v1?: DigestAction;
|
|
28
|
+
v1Issues?: string[];
|
|
29
|
+
visibility?: VisibilityAction;
|
|
30
|
+
skipped?: JungleSkip;
|
|
31
|
+
code?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface JungleImportReport {
|
|
34
|
+
schemaVersion: 1;
|
|
35
|
+
dryRun: boolean;
|
|
36
|
+
captureVerification: 'deferred' | 'verified';
|
|
37
|
+
notes: number;
|
|
38
|
+
counts: {
|
|
39
|
+
newItems: number;
|
|
40
|
+
existingItems: number;
|
|
41
|
+
editorial: Record<string, number>;
|
|
42
|
+
v1: Record<string, number>;
|
|
43
|
+
visibility: Record<string, number>;
|
|
44
|
+
skipped: Record<string, number>;
|
|
45
|
+
};
|
|
46
|
+
entries: JungleEntry[];
|
|
47
|
+
nextOffset: number | null;
|
|
48
|
+
}
|
|
49
|
+
/** Digest v1 only when the note converts with nothing dropped or trimmed and passes every rule against this revision's Markdown. */
|
|
50
|
+
export declare function jungleV1(editorial: JungleEditorial, markdown: string): {
|
|
51
|
+
v1?: DigestV1;
|
|
52
|
+
issues: string[];
|
|
53
|
+
};
|
|
54
|
+
export declare function importJungle(store: LibraryStore, jungle: string, options?: JungleImportOptions): Promise<JungleImportReport>;
|