@chronicle.app/obsidian 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # @chronicle.app/obsidian
2
+
3
+ Import an Obsidian vault as Markdown documents, without requiring a particular
4
+ folder structure, tagging convention, person note, or custom properties.
5
+
6
+ ## Usage
7
+
8
+ ```bash
9
+ chronicle extract obsidian --input /path/to/vault
10
+ chronicle extract obsidian --input /path/to/vault/Books --limit 1
11
+ ```
12
+
13
+ `--input` accepts a vault or a folder within it. The nearest ancestor containing
14
+ `.obsidian` supplies the vault name and relative paths, so a subfolder import
15
+ uses the same document identities as a full import. Without `.obsidian`, the
16
+ input directory is treated as the vault. `--limit` limits emitted notes (`0`
17
+ means unlimited); all vault note paths remain available for link resolution.
18
+
19
+ ## Imported data
20
+
21
+ Every `.md` file is imported as a `DocumentObject`, including untagged notes.
22
+ Hidden files/directories, symlinks, and non-Markdown attachments are skipped.
23
+
24
+ - Identity: `(source, handle, inRealm.handle)`, where `handle` is the complete
25
+ vault-relative filename and the realm handle is the vault folder name.
26
+ - Name: filename without its extension.
27
+ - Body: Markdown with leading YAML frontmatter omitted; other content, including
28
+ Dataview syntax and whitespace, is preserved.
29
+ - Tags: the standard frontmatter `tags` property, preserving case and nested tags.
30
+ - References: resolved wikilinks and inline Markdown links to other notes;
31
+ external HTTP(S) Markdown links reference URL entities.
32
+ - Modification: one `UpdateAction` at the file's modification time, attributed to
33
+ the Chronicle owner through `@me`. No owner note is needed.
34
+
35
+ Wikilinks and note embeds support folder paths, display labels, headings and block anchors.
36
+ Relative Markdown note links are resolved against the source note's directory.
37
+ Repeated links are deduplicated. Unresolved or ambiguous targets stay in the
38
+ body without inventing a document. Code examples and comments are ignored when
39
+ recognizing links. This v1 does not parse the entire Markdown grammar: reference
40
+ style links, inline hashtags, and attachment contents remain in the body only.
41
+
42
+ Custom properties such as `type`, `birthday`, `goodreads_id`, or `letterboxd_url`
43
+ remain in the raw extraction metadata, outside the document body. They do not create people,
44
+ books, `sameAs` edges, or real-world actions. Aliases, including `@me`, do not
45
+ change a document's identity. There is no mapping configuration.
46
+
47
+ Filesystem birth time, when available, is retained in the raw record as
48
+ `birthtime`. It is not treated as an authored `CreateAction`: copying/restoring
49
+ a file can change it. Modification time records the current file state, not a
50
+ reconstructed history of every edit. Unchanged files have a deterministic
51
+ snapshot time based on the newest imported file modification time.
52
+
53
+ Notes are extracted newest-modified first, reading each body only when it is
54
+ emitted. Reruns scan every note: an already-imported path may still contain
55
+ edits, so the key-only ETL frontier cannot safely stop the scan. Complete,
56
+ unlimited scans also allow deleted notes to be detected.
57
+
58
+ The plugin provides `obsidian://open?vault=…&file=…` deep links from document
59
+ identities. Renaming a file changes its path identity; this v1 does not track
60
+ renames. Vaults with the same folder name share the same realm namespace.
61
+
62
+ ## Development
63
+
64
+ ```bash
65
+ npm test --workspace=@chronicle.app/obsidian
66
+ npm run build --workspace=@chronicle.app/obsidian
67
+ ```
68
+
69
+ Tests build a small synthetic vault in a temporary directory and run it through
70
+ the extractor and transformer, so they never read a real vault or other host
71
+ data.
@@ -0,0 +1,51 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import ObsidianTransformer from './ObsidianTransformer.js';
3
+ export interface VaultNote {
4
+ /** Path relative to the enclosing vault, including .md. */
5
+ path: string;
6
+ name: string;
7
+ tags: string[];
8
+ frontmatter: {
9
+ [key: string]: unknown;
10
+ };
11
+ /** Markdown after the YAML frontmatter, preserving plugin-specific syntax. */
12
+ body: string;
13
+ /** Filesystem birth time, when available; not an authored creation event. */
14
+ birthtime?: string;
15
+ mtime: string;
16
+ }
17
+ export interface VaultContext {
18
+ recordType: string;
19
+ vault: string;
20
+ /** All Markdown paths in the vault, including notes outside the input folder. */
21
+ paths: string[];
22
+ }
23
+ export declare class ObsidianExtractor extends Extractor<typeof ObsidianExtractor> {
24
+ static source: string;
25
+ static description: string;
26
+ static delivery: "local";
27
+ static strategy: string;
28
+ static recordTypes: string[];
29
+ static default: boolean;
30
+ static temporality: "snapshot";
31
+ static newestFirst: boolean;
32
+ static defaultTransformer: typeof ObsidianTransformer;
33
+ static schema: any;
34
+ private entries;
35
+ private context;
36
+ private newestMtime?;
37
+ setup(): Promise<void>;
38
+ /** Prefer the enclosing Obsidian vault so subfolder imports retain identity. */
39
+ private vaultRoot;
40
+ /** Re-reading unchanged files produces the same snapshot time. */
41
+ protected asOfTime(): Date;
42
+ keyOf(record: Record): string | null;
43
+ determineCount(): Promise<number | null>;
44
+ extract(): AsyncGenerator<Record>;
45
+ private readNote;
46
+ /** Ignore hidden files/directories and symlinks; only Markdown is imported. */
47
+ private findMarkdownFiles;
48
+ private frontmatter;
49
+ private tags;
50
+ }
51
+ //# sourceMappingURL=ObsidianExtractor.d.ts.map
@@ -0,0 +1,151 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Extractor } from '@chronicle.app/etl';
4
+ import { load, CORE_SCHEMA } from 'js-yaml';
5
+ import { z } from 'zod';
6
+ import ObsidianTransformer from './ObsidianTransformer.js';
7
+ const FRONTMATTER = /^(?:\uFEFF)?---\r?\n((?:[^\n]*\n)*?)---(?:\r?\n|$)/;
8
+ export class ObsidianExtractor extends Extractor {
9
+ static source = 'obsidian';
10
+ static description = 'Notes and links from a vault';
11
+ static delivery = 'local';
12
+ static strategy = 'vault';
13
+ static recordTypes = ['notes'];
14
+ static default = true;
15
+ static temporality = 'snapshot';
16
+ // Paths stay known when notes change. The key-only frontier would stop
17
+ // after 25 existing notes even if more edits remain, so always scan the vault.
18
+ static newestFirst = false;
19
+ static defaultTransformer = ObsidianTransformer;
20
+ static schema = Extractor.schema.extend({
21
+ input: z.string().describe('Path to an Obsidian vault or a folder within it'),
22
+ });
23
+ entries = [];
24
+ context;
25
+ newestMtime;
26
+ async setup() {
27
+ this.entries = [];
28
+ this.newestMtime = undefined;
29
+ const input = path.resolve(String(this.config.input));
30
+ const root = await this.vaultRoot(input);
31
+ const files = await this.findMarkdownFiles(root);
32
+ this.context = {
33
+ recordType: 'notes',
34
+ vault: path.basename(root),
35
+ paths: files.map(file => path.relative(root, file).split(path.sep).join('/')),
36
+ };
37
+ for (const file of files) {
38
+ const relativeToInput = path.relative(input, file);
39
+ if (relativeToInput.startsWith(`..${path.sep}`) || relativeToInput === '..')
40
+ continue;
41
+ const stat = await fs.stat(file);
42
+ if (!this.newestMtime || stat.mtime > this.newestMtime)
43
+ this.newestMtime = stat.mtime;
44
+ this.entries.push({
45
+ file,
46
+ relative: path.relative(root, file).split(path.sep).join('/'),
47
+ mtime: stat.mtime,
48
+ ...(stat.birthtimeMs > 0 && {
49
+ birthtime: stat.birthtime.toISOString(),
50
+ }),
51
+ });
52
+ }
53
+ // Newest-first for limited imports; path order breaks ties so a
54
+ // re-read of an unchanged vault yields the same stream.
55
+ this.entries.sort((a, b) => b.mtime.getTime() - a.mtime.getTime() || a.relative.localeCompare(b.relative));
56
+ this.logVerboseStep(`Vault "${this.context.vault}": ${this.entries.length} Markdown notes`);
57
+ }
58
+ /** Prefer the enclosing Obsidian vault so subfolder imports retain identity. */
59
+ async vaultRoot(input) {
60
+ let dir = input;
61
+ do {
62
+ try {
63
+ if ((await fs.stat(path.join(dir, '.obsidian'))).isDirectory())
64
+ return dir;
65
+ }
66
+ catch (error) {
67
+ if (error.code !== 'ENOENT')
68
+ throw error;
69
+ }
70
+ const parent = path.dirname(dir);
71
+ if (parent === dir)
72
+ return input;
73
+ dir = parent;
74
+ } while (dir !== path.dirname(dir));
75
+ return input;
76
+ }
77
+ /** Re-reading unchanged files produces the same snapshot time. */
78
+ asOfTime() {
79
+ return this.newestMtime ?? super.asOfTime();
80
+ }
81
+ keyOf(record) {
82
+ return record.data.path ?? null;
83
+ }
84
+ async determineCount() {
85
+ const limit = this.getEffectiveLimit();
86
+ return limit === null ? this.entries.length : Math.min(limit, this.entries.length);
87
+ }
88
+ async *extract() {
89
+ let count = 0;
90
+ for (const entry of this.entries) {
91
+ if (this.shouldStopExtracting(count))
92
+ return;
93
+ yield this.createRecord(await this.readNote(entry), this.context);
94
+ count++;
95
+ }
96
+ }
97
+ async readNote(entry) {
98
+ const body = await fs.readFile(entry.file, 'utf8');
99
+ const frontmatter = this.frontmatter(body, entry.relative);
100
+ return {
101
+ path: entry.relative,
102
+ name: path.basename(entry.file, path.extname(entry.file)),
103
+ tags: this.tags(frontmatter),
104
+ frontmatter,
105
+ body: body.replace(FRONTMATTER, ''),
106
+ ...(entry.birthtime !== undefined && { birthtime: entry.birthtime }),
107
+ mtime: entry.mtime.toISOString(),
108
+ };
109
+ }
110
+ /** Ignore hidden files/directories and symlinks; only Markdown is imported. */
111
+ async findMarkdownFiles(root) {
112
+ const files = [];
113
+ const walk = async (dir) => {
114
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
115
+ if (entry.name.startsWith('.'))
116
+ continue;
117
+ const full = path.join(dir, entry.name);
118
+ if (entry.isDirectory())
119
+ await walk(full);
120
+ else if (entry.isFile() && path.extname(entry.name).toLowerCase() === '.md')
121
+ files.push(full);
122
+ }
123
+ };
124
+ await walk(root);
125
+ return files.sort((a, b) => a.localeCompare(b));
126
+ }
127
+ frontmatter(body, file) {
128
+ const match = FRONTMATTER.exec(body);
129
+ if (!match)
130
+ return {};
131
+ try {
132
+ const loaded = load(match[1], { schema: CORE_SCHEMA });
133
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded))
134
+ return loaded;
135
+ }
136
+ catch (error) {
137
+ this.logger.warn(`Unparseable frontmatter in ${file}: ${error instanceof Error ? error.message : String(error)}`);
138
+ }
139
+ return {};
140
+ }
141
+ tags(frontmatter) {
142
+ const raw = frontmatter.tags;
143
+ const values = Array.isArray(raw) ? raw : typeof raw === 'string' ? raw.split(/[\s,]+/) : [];
144
+ return [
145
+ ...new Set(values
146
+ .filter((value) => typeof value === 'string')
147
+ .map(value => value.trim().replace(/^#/, ''))
148
+ .filter(Boolean)),
149
+ ];
150
+ }
151
+ }
@@ -0,0 +1,11 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { UpdateAction } from '@chronicle.app/schema';
3
+ /** Every note is a document; custom properties never infer a real-world subject. */
4
+ export default class ObsidianTransformer extends ChronicleTransformer {
5
+ transform(record: Record): Promise<UpdateAction[]>;
6
+ private document;
7
+ private references;
8
+ /** Resolve only existing notes; never guess among ambiguous basenames. */
9
+ private resolve;
10
+ }
11
+ //# sourceMappingURL=ObsidianTransformer.d.ts.map
@@ -0,0 +1,104 @@
1
+ import path from 'node:path';
2
+ import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
3
+ const SOURCE = 'obsidian';
4
+ const NOTE_KEY = ['source', 'handle', 'inRealm.handle'];
5
+ const withExtension = (value) => (/\.md$/i.test(value) ? value : `${value}.md`);
6
+ /** Every note is a document; custom properties never infer a real-world subject. */
7
+ export default class ObsidianTransformer extends ChronicleTransformer {
8
+ async transform(record) {
9
+ if (record.extraction.recordType !== 'notes')
10
+ return [];
11
+ const note = record.data;
12
+ const ctx = record.context;
13
+ const document = this.document(note.path, ctx);
14
+ document.body = note.body;
15
+ document.tags = note.tags;
16
+ document.references = this.references(note, ctx);
17
+ return [
18
+ {
19
+ '@type': 'UpdateAction',
20
+ '@key': ['@type', 'source', 'object.handle', 'object.inRealm.handle', 'timestamp'],
21
+ source: SOURCE,
22
+ timestamp: note.mtime,
23
+ agent: selfAgent({ source: SOURCE }),
24
+ object: document,
25
+ },
26
+ ];
27
+ }
28
+ document(handle, ctx) {
29
+ const realm = {
30
+ '@type': 'Realm',
31
+ '@key': ['@type', 'source', 'handle'],
32
+ source: SOURCE,
33
+ handle: ctx.vault,
34
+ };
35
+ return {
36
+ '@type': 'DocumentObject',
37
+ '@key': NOTE_KEY,
38
+ source: SOURCE,
39
+ handle,
40
+ name: path.posix.basename(handle, path.posix.extname(handle)),
41
+ inRealm: realm,
42
+ mimeType: 'text/markdown',
43
+ };
44
+ }
45
+ references(note, ctx) {
46
+ const references = new Map();
47
+ // Ignore code examples and comments when recognizing links. The original
48
+ // Markdown is preserved verbatim on the document.
49
+ const text = note.body
50
+ .replaceAll(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1[^\n]*(?:\n|$)/gm, '')
51
+ .replaceAll(/(`+)[^\n]*?\1/g, '')
52
+ .replaceAll(/<!--[^]*?-->|%%[^]*?%%/g, '');
53
+ const add = (raw, wiki) => {
54
+ let target = raw.trim();
55
+ if (/^https?:\/\//i.test(target)) {
56
+ try {
57
+ const url = new URL(target).href;
58
+ references.set(url, {
59
+ '@type': 'Entity',
60
+ '@key': ['url'],
61
+ source: 'url',
62
+ url,
63
+ });
64
+ }
65
+ catch {
66
+ /* Keep malformed links in the body only. */
67
+ }
68
+ return;
69
+ }
70
+ if (/^[a-z][a-z\d+.-]*:/i.test(target))
71
+ return;
72
+ try {
73
+ target = decodeURIComponent(target);
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ target = target.split('#')[0];
79
+ if (!target)
80
+ return;
81
+ const resolved = this.resolve(target, note.path, ctx.paths, wiki);
82
+ if (resolved && resolved !== note.path)
83
+ references.set(resolved, this.document(resolved, ctx));
84
+ };
85
+ for (const match of text.matchAll(/(?<!\\)\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g))
86
+ add(match[1], true);
87
+ for (const match of text.matchAll(/(?<!!)(?<!\\)\[[^\]\n]*\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+"[^"\n]*")?\s*\)/g))
88
+ add(match[1] ?? match[2], false);
89
+ return [...references.values()];
90
+ }
91
+ /** Resolve only existing notes; never guess among ambiguous basenames. */
92
+ resolve(target, from, paths, wiki) {
93
+ const root = withExtension(path.posix.normalize(target.replace(/^\//, '')));
94
+ const relative = withExtension(path.posix.normalize(path.posix.join(path.posix.dirname(from), target)));
95
+ const candidates = wiki && !target.startsWith('.') ? [root, relative] : [relative, root];
96
+ for (const candidate of candidates)
97
+ if (paths.includes(candidate))
98
+ return candidate;
99
+ if (!wiki || target.startsWith('.'))
100
+ return undefined;
101
+ const matches = paths.filter(value => value.endsWith(`/${root}`));
102
+ return matches.length === 1 ? matches[0] : undefined;
103
+ }
104
+ }
@@ -0,0 +1,3 @@
1
+ export { ObsidianExtractor } from './connectors/ObsidianExtractor.js';
2
+ export { default as ObsidianTransformer } from './connectors/ObsidianTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ObsidianExtractor } from './connectors/ObsidianExtractor.js';
2
+ export { default as ObsidianTransformer } from './connectors/ObsidianTransformer.js';
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@chronicle.app/obsidian",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "obsidian"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for Obsidian Markdown documents and note links",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/js-yaml": "^4.0.9",
29
+ "@types/node": "^22.10.1",
30
+ "typescript": "^5.7.2"
31
+ },
32
+ "homepage": "https://chronicle.app",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/chronicle-app/chronicle.git",
36
+ "directory": "plugins/obsidian"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "dependencies": {
43
+ "js-yaml": "^4.1.0",
44
+ "zod": "^3.24.1"
45
+ },
46
+ "peerDependencies": {
47
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
48
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.13.0"
52
+ },
53
+ "exports": {
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "import": "./dist/index.js"
57
+ }
58
+ },
59
+ "chronicle": {
60
+ "plugin": true,
61
+ "sources": {
62
+ "obsidian": {
63
+ "strategies": {
64
+ "vault": {
65
+ "delivery": "local",
66
+ "recordTypes": [
67
+ "notes"
68
+ ],
69
+ "default": true
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }