@grapgrap/aeira 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +92 -0
- package/README.md +92 -0
- package/dist/cli.js +583 -0
- package/package.json +47 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# aeira
|
|
2
|
+
|
|
3
|
+
A CLI tool that structures markdown wikilink relationships without app dependency.
|
|
4
|
+
|
|
5
|
+
[English](README.en.md) [한국어](README.md)
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Wikilinks (`[[]]`) in markdown documents express relationships between documents.
|
|
10
|
+
But as wikilinks accumulate, the full picture of those relationships remains invisible. Which documents reference which, and how many steps connect two documents -- you cannot know without tracing them yourself.
|
|
11
|
+
|
|
12
|
+
aeira builds these relationships into a directed graph, navigable from the command line.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
aeira operates through four commands.
|
|
17
|
+
|
|
18
|
+
### init
|
|
19
|
+
|
|
20
|
+
Register a document collection by specifying a source path.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
aeira init ./my-docs
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### sync
|
|
27
|
+
|
|
28
|
+
Parse wikilinks to build and update the graph. Only changed documents are processed incrementally.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
aeira sync # use cwd as source
|
|
32
|
+
aeira sync -s ./my-docs # specify source path
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### search
|
|
36
|
+
|
|
37
|
+
Search documents by keyword and display outgoing links alongside each result.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
aeira search "keyword"
|
|
41
|
+
aeira search -s ./my-docs "keyword"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### graph
|
|
45
|
+
|
|
46
|
+
Three primitives for navigating the graph.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
# List 1-hop neighbors
|
|
50
|
+
aeira graph neighbors node-name
|
|
51
|
+
|
|
52
|
+
# Find all paths between two nodes
|
|
53
|
+
aeira graph path from-node to-node
|
|
54
|
+
|
|
55
|
+
# Show entire graph
|
|
56
|
+
aeira graph all
|
|
57
|
+
|
|
58
|
+
# Specify source path
|
|
59
|
+
aeira graph neighbors -s ./my-docs node-name
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
All query commands support JSON output with the `--json` flag.
|
|
63
|
+
|
|
64
|
+
## Getting Started
|
|
65
|
+
|
|
66
|
+
### Prerequisites
|
|
67
|
+
|
|
68
|
+
- Node.js >= 22
|
|
69
|
+
- [ir](https://github.com/vlwkaos/ir) -- `brew install vlwkaos/tap/ir`
|
|
70
|
+
|
|
71
|
+
### Installation
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npm install -g aeira
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### First Use
|
|
78
|
+
|
|
79
|
+
Build the graph by specifying a source path.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
aeira init ./my-docs
|
|
83
|
+
cd ./my-docs
|
|
84
|
+
aeira sync
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
After sync, you can navigate the graph.
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
aeira graph neighbors some-document
|
|
91
|
+
aeira search "query"
|
|
92
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# aeira
|
|
2
|
+
|
|
3
|
+
마크다운 위키링크의 관계를 앱 종속 없이 구조화하는 CLI 도구.
|
|
4
|
+
|
|
5
|
+
[English](README.en.md) [한국어](README.md)
|
|
6
|
+
|
|
7
|
+
## 왜 만들었는가
|
|
8
|
+
|
|
9
|
+
마크다운 문서에서 위키링크(`[[]]`)는 문서 간 관계를 표현한다.
|
|
10
|
+
하지만 위키링크가 늘어나도 관계의 전체 그림은 보이지 않는다. 어떤 문서가 어떤 문서를 참조하는지, 두 문서가 몇 단계를 거쳐 연결되는지는 직접 따라가 보기 전에는 알 수 없다.
|
|
11
|
+
|
|
12
|
+
aeira는 이 관계를 방향 그래프로 구축하여 커맨드라인에서 탐색할 수 있게 한다.
|
|
13
|
+
|
|
14
|
+
## 명령어
|
|
15
|
+
|
|
16
|
+
aeira는 네 가지 명령으로 동작한다.
|
|
17
|
+
|
|
18
|
+
### init
|
|
19
|
+
|
|
20
|
+
source 경로를 지정하여 문서 컬렉션을 등록한다.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
aeira init ./my-docs
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### sync
|
|
27
|
+
|
|
28
|
+
위키링크를 파싱하여 그래프를 구축하고 갱신한다. 변경된 문서만 증분 처리한다.
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
aeira sync # cwd를 source로 사용
|
|
32
|
+
aeira sync -s ./my-docs # source 경로 지정
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### search
|
|
36
|
+
|
|
37
|
+
키워드로 문서를 검색하고, 각 결과의 outgoing links를 함께 표시한다.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
aeira search "키워드"
|
|
41
|
+
aeira search -s ./my-docs "키워드"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### graph
|
|
45
|
+
|
|
46
|
+
그래프를 탐색하는 세 가지 프리미티브를 제공한다.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
# 1-hop 이웃 조회
|
|
50
|
+
aeira graph neighbors node-name
|
|
51
|
+
|
|
52
|
+
# 두 노드 간 경로 탐색
|
|
53
|
+
aeira graph path from-node to-node
|
|
54
|
+
|
|
55
|
+
# 전체 그래프 출력
|
|
56
|
+
aeira graph all
|
|
57
|
+
|
|
58
|
+
# source 경로 지정
|
|
59
|
+
aeira graph neighbors -s ./my-docs node-name
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
모든 조회 명령은 `--json` 플래그로 JSON 출력을 지원한다.
|
|
63
|
+
|
|
64
|
+
## 시작하기
|
|
65
|
+
|
|
66
|
+
### 사전 요구
|
|
67
|
+
|
|
68
|
+
- Node.js >= 22
|
|
69
|
+
- [ir](https://github.com/vlwkaos/ir) -- `brew install vlwkaos/tap/ir`
|
|
70
|
+
|
|
71
|
+
### 설치
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
npm install -g aeira
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 첫 사용
|
|
78
|
+
|
|
79
|
+
source 경로를 지정하여 그래프를 구축한다.
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
aeira init ./my-docs
|
|
83
|
+
cd ./my-docs
|
|
84
|
+
aeira sync
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
sync 이후 그래프를 탐색할 수 있다.
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
aeira graph neighbors some-document
|
|
91
|
+
aeira search "검색어"
|
|
92
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { defineCommand, runMain } from "citty";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { z } from "zod/v4";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import Database from "better-sqlite3";
|
|
8
|
+
//#region src/ir.ts
|
|
9
|
+
function isNotFound(error) {
|
|
10
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
11
|
+
}
|
|
12
|
+
const searchResultRow = z.object({
|
|
13
|
+
path: z.string(),
|
|
14
|
+
title: z.string(),
|
|
15
|
+
score: z.number(),
|
|
16
|
+
snippet: z.string().nullable().optional()
|
|
17
|
+
});
|
|
18
|
+
function searchCollection(collection, query) {
|
|
19
|
+
try {
|
|
20
|
+
const output = execFileSync("ir", [
|
|
21
|
+
"search",
|
|
22
|
+
query,
|
|
23
|
+
"-c",
|
|
24
|
+
collection,
|
|
25
|
+
"--json"
|
|
26
|
+
], { encoding: "utf-8" });
|
|
27
|
+
return z.array(searchResultRow).parse(JSON.parse(output));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (isNotFound(error)) throw new Error("ir이 설치되어 있지 않습니다. brew install vlwkaos/tap/ir로 설치하세요.", { cause: error });
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function updateCollection(collection) {
|
|
34
|
+
try {
|
|
35
|
+
execFileSync("ir", ["update", collection], { stdio: "inherit" });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (isNotFound(error)) throw new Error("ir이 설치되어 있지 않습니다. brew install vlwkaos/tap/ir로 설치하세요.", { cause: error });
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function initCollection(collection, sourcePath) {
|
|
42
|
+
try {
|
|
43
|
+
execFileSync("ir", [
|
|
44
|
+
"collection",
|
|
45
|
+
"add",
|
|
46
|
+
collection,
|
|
47
|
+
sourcePath
|
|
48
|
+
], { stdio: "inherit" });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (isNotFound(error)) throw new Error("ir이 설치되어 있지 않습니다. brew install vlwkaos/tap/ir로 설치하세요.", { cause: error });
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
updateCollection(collection);
|
|
54
|
+
}
|
|
55
|
+
z.object({ hash: z.string() });
|
|
56
|
+
const edgeRow = z.object({
|
|
57
|
+
source_path: z.string(),
|
|
58
|
+
target_path: z.string()
|
|
59
|
+
});
|
|
60
|
+
const documentContentRow = z.object({
|
|
61
|
+
path: z.string(),
|
|
62
|
+
content: z.string(),
|
|
63
|
+
hash: z.string()
|
|
64
|
+
});
|
|
65
|
+
const pathRow = z.object({ path: z.string() });
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/store/utils.ts
|
|
68
|
+
const DEFAULT_IR_CONFIG_DIR = join(homedir(), ".config", "ir");
|
|
69
|
+
function getCollectionDbPath(collectionName, irConfigDir = DEFAULT_IR_CONFIG_DIR) {
|
|
70
|
+
return join(irConfigDir, "collections", `${collectionName}.sqlite`);
|
|
71
|
+
}
|
|
72
|
+
function parsePaths(rows) {
|
|
73
|
+
return z.array(pathRow).parse(rows).map((row) => row.path);
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/store/store.ts
|
|
77
|
+
function ensureSchema(database) {
|
|
78
|
+
database.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS aeira_edges (
|
|
80
|
+
source_path TEXT NOT NULL,
|
|
81
|
+
target_path TEXT NOT NULL,
|
|
82
|
+
PRIMARY KEY (source_path, target_path)
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS aeira_sync_state (
|
|
85
|
+
source_path TEXT PRIMARY KEY,
|
|
86
|
+
hash TEXT NOT NULL
|
|
87
|
+
);
|
|
88
|
+
`);
|
|
89
|
+
}
|
|
90
|
+
function createStore(database) {
|
|
91
|
+
ensureSchema(database);
|
|
92
|
+
const insertEdge = database.prepare("INSERT INTO aeira_edges (source_path, target_path) VALUES (?, ?)");
|
|
93
|
+
const insertSyncState = database.prepare("INSERT INTO aeira_sync_state (source_path, hash) VALUES (?, ?)");
|
|
94
|
+
const selectAllEdges = database.prepare("SELECT source_path, target_path FROM aeira_edges");
|
|
95
|
+
const selectActiveDocuments = database.prepare("SELECT path FROM documents WHERE active = 1");
|
|
96
|
+
const selectAddedDocuments = database.prepare(`
|
|
97
|
+
SELECT d.path FROM documents d
|
|
98
|
+
LEFT JOIN aeira_sync_state s ON d.path = s.source_path
|
|
99
|
+
WHERE d.active = 1 AND s.source_path IS NULL
|
|
100
|
+
`);
|
|
101
|
+
const selectChangedDocuments = database.prepare(`
|
|
102
|
+
SELECT d.path FROM documents d
|
|
103
|
+
JOIN aeira_sync_state s ON d.path = s.source_path
|
|
104
|
+
WHERE d.active = 1 AND d.hash != s.hash
|
|
105
|
+
`);
|
|
106
|
+
const selectRemovedDocuments = database.prepare(`
|
|
107
|
+
SELECT s.source_path as path FROM aeira_sync_state s
|
|
108
|
+
LEFT JOIN documents d ON s.source_path = d.path AND d.active = 1
|
|
109
|
+
WHERE d.id IS NULL
|
|
110
|
+
`);
|
|
111
|
+
const readDocumentContent = database.prepare("SELECT d.path, c.doc as content, d.hash FROM documents d JOIN content c ON d.hash = c.hash WHERE d.active = 1 AND d.path = ?");
|
|
112
|
+
const deleteEdgesBySource = database.prepare("DELETE FROM aeira_edges WHERE source_path = ?");
|
|
113
|
+
const deleteSyncStateBySource = database.prepare("DELETE FROM aeira_sync_state WHERE source_path = ?");
|
|
114
|
+
return {
|
|
115
|
+
loadEdges() {
|
|
116
|
+
const rows = z.array(edgeRow).parse(selectAllEdges.all());
|
|
117
|
+
const activeDocumentPaths = new Set(z.array(pathRow).parse(selectActiveDocuments.all()).map((row) => row.path));
|
|
118
|
+
const nodes = new Set(activeDocumentPaths);
|
|
119
|
+
const dangling = /* @__PURE__ */ new Set();
|
|
120
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
121
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
122
|
+
for (const { source_path, target_path } of rows) {
|
|
123
|
+
nodes.add(source_path);
|
|
124
|
+
nodes.add(target_path);
|
|
125
|
+
if (!activeDocumentPaths.has(target_path)) dangling.add(target_path);
|
|
126
|
+
let targets = outgoing.get(source_path);
|
|
127
|
+
if (!targets) {
|
|
128
|
+
targets = /* @__PURE__ */ new Set();
|
|
129
|
+
outgoing.set(source_path, targets);
|
|
130
|
+
}
|
|
131
|
+
targets.add(target_path);
|
|
132
|
+
let sources = incoming.get(target_path);
|
|
133
|
+
if (!sources) {
|
|
134
|
+
sources = /* @__PURE__ */ new Set();
|
|
135
|
+
incoming.set(target_path, sources);
|
|
136
|
+
}
|
|
137
|
+
sources.add(source_path);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
nodes,
|
|
141
|
+
dangling,
|
|
142
|
+
outgoing,
|
|
143
|
+
incoming
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
getChangedDocuments() {
|
|
147
|
+
return {
|
|
148
|
+
added: parsePaths(selectAddedDocuments.all()),
|
|
149
|
+
changed: parsePaths(selectChangedDocuments.all()),
|
|
150
|
+
removed: parsePaths(selectRemovedDocuments.all())
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
getActiveDocumentPaths() {
|
|
154
|
+
return parsePaths(selectActiveDocuments.all());
|
|
155
|
+
},
|
|
156
|
+
readDocumentContents(paths) {
|
|
157
|
+
const results = [];
|
|
158
|
+
for (const path of paths) {
|
|
159
|
+
const raw = readDocumentContent.get(path);
|
|
160
|
+
if (raw) results.push(documentContentRow.parse(raw));
|
|
161
|
+
}
|
|
162
|
+
return results;
|
|
163
|
+
},
|
|
164
|
+
purgeDocuments(sourcePaths) {
|
|
165
|
+
database.transaction(() => {
|
|
166
|
+
for (const path of sourcePaths) {
|
|
167
|
+
deleteEdgesBySource.run(path);
|
|
168
|
+
deleteSyncStateBySource.run(path);
|
|
169
|
+
}
|
|
170
|
+
})();
|
|
171
|
+
},
|
|
172
|
+
syncDocuments(entries) {
|
|
173
|
+
database.transaction(() => {
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
for (const targetPath of entry.targetPaths) insertEdge.run(entry.sourcePath, targetPath);
|
|
176
|
+
insertSyncState.run(entry.sourcePath, entry.hash);
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//#endregion
|
|
183
|
+
//#region src/store/readonly-store.ts
|
|
184
|
+
function createReadonlyStore(database) {
|
|
185
|
+
const selectAllEdges = database.prepare("SELECT source_path, target_path FROM aeira_edges");
|
|
186
|
+
const selectActiveDocuments = database.prepare("SELECT path FROM documents WHERE active = 1");
|
|
187
|
+
return { loadEdges() {
|
|
188
|
+
const rows = z.array(edgeRow).parse(selectAllEdges.all());
|
|
189
|
+
const activeDocumentPaths = new Set(z.array(pathRow).parse(selectActiveDocuments.all()).map((row) => row.path));
|
|
190
|
+
const nodes = new Set(activeDocumentPaths);
|
|
191
|
+
const dangling = /* @__PURE__ */ new Set();
|
|
192
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
193
|
+
const incoming = /* @__PURE__ */ new Map();
|
|
194
|
+
for (const { source_path, target_path } of rows) {
|
|
195
|
+
nodes.add(source_path);
|
|
196
|
+
nodes.add(target_path);
|
|
197
|
+
if (!activeDocumentPaths.has(target_path)) dangling.add(target_path);
|
|
198
|
+
let targets = outgoing.get(source_path);
|
|
199
|
+
if (!targets) {
|
|
200
|
+
targets = /* @__PURE__ */ new Set();
|
|
201
|
+
outgoing.set(source_path, targets);
|
|
202
|
+
}
|
|
203
|
+
targets.add(target_path);
|
|
204
|
+
let sources = incoming.get(target_path);
|
|
205
|
+
if (!sources) {
|
|
206
|
+
sources = /* @__PURE__ */ new Set();
|
|
207
|
+
incoming.set(target_path, sources);
|
|
208
|
+
}
|
|
209
|
+
sources.add(source_path);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
nodes,
|
|
213
|
+
dangling,
|
|
214
|
+
outgoing,
|
|
215
|
+
incoming
|
|
216
|
+
};
|
|
217
|
+
} };
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/commands/init.ts
|
|
221
|
+
const init = defineCommand({
|
|
222
|
+
meta: {
|
|
223
|
+
name: "init",
|
|
224
|
+
description: "Initialize ir collection for source"
|
|
225
|
+
},
|
|
226
|
+
args: { source: {
|
|
227
|
+
type: "positional",
|
|
228
|
+
description: "source directory path",
|
|
229
|
+
required: true
|
|
230
|
+
} },
|
|
231
|
+
run({ args }) {
|
|
232
|
+
const sourcePath = resolve(args.source);
|
|
233
|
+
const collection = basename(sourcePath);
|
|
234
|
+
if (existsSync(getCollectionDbPath(collection))) {
|
|
235
|
+
console.log(`Already initialized: ${collection}`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
initCollection(collection, sourcePath);
|
|
239
|
+
console.log(`Initialized: ${collection}`);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/graph/graph.ts
|
|
244
|
+
function buildNameIndex(paths) {
|
|
245
|
+
const index = /* @__PURE__ */ new Map();
|
|
246
|
+
for (const path of paths) {
|
|
247
|
+
const stem = basename(path, ".md");
|
|
248
|
+
if (!index.has(stem)) index.set(stem, path);
|
|
249
|
+
}
|
|
250
|
+
return index;
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/graph/query.ts
|
|
254
|
+
function neighbors(graph, node, direction = "both") {
|
|
255
|
+
const outgoing = direction !== "incoming" ? graph.outgoing.get(node) : void 0;
|
|
256
|
+
const incoming = direction !== "outgoing" ? graph.incoming.get(node) : void 0;
|
|
257
|
+
return [...new Set([...outgoing ?? [], ...incoming ?? []])].toSorted();
|
|
258
|
+
}
|
|
259
|
+
function findPaths(graph, from, to, maxPaths = 20) {
|
|
260
|
+
const results = [];
|
|
261
|
+
function traverse(current, path, visited) {
|
|
262
|
+
if (results.length >= maxPaths) return;
|
|
263
|
+
if (current === to) {
|
|
264
|
+
results.push([...path]);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const targets = graph.outgoing.get(current);
|
|
268
|
+
if (!targets) return;
|
|
269
|
+
for (const next of targets) {
|
|
270
|
+
if (visited.has(next)) continue;
|
|
271
|
+
visited.add(next);
|
|
272
|
+
path.push(next);
|
|
273
|
+
traverse(next, path, visited);
|
|
274
|
+
path.pop();
|
|
275
|
+
visited.delete(next);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
traverse(from, [from], new Set([from]));
|
|
279
|
+
return results;
|
|
280
|
+
}
|
|
281
|
+
function snapshot(graph) {
|
|
282
|
+
const nodes = [...graph.nodes].toSorted();
|
|
283
|
+
let edges = [];
|
|
284
|
+
for (const [source, targets] of graph.outgoing) for (const target of targets) edges.push([source, target]);
|
|
285
|
+
edges = edges.toSorted((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1]));
|
|
286
|
+
return {
|
|
287
|
+
nodes,
|
|
288
|
+
edges
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/wikilink.ts
|
|
293
|
+
function parseWikiLinks(text) {
|
|
294
|
+
const cleaned = stripExcludedRegions(text);
|
|
295
|
+
const pattern = /(?<!!)\[\[([^[\]|]+)(?:\|([^[\]]+))?\]\]/g;
|
|
296
|
+
const links = [];
|
|
297
|
+
let match;
|
|
298
|
+
while ((match = pattern.exec(cleaned)) !== null) {
|
|
299
|
+
const target = match[1].trim();
|
|
300
|
+
const alias = match[2]?.trim();
|
|
301
|
+
if (!target || target.includes("#") || target.includes("/")) continue;
|
|
302
|
+
links.push(alias ? {
|
|
303
|
+
target,
|
|
304
|
+
alias
|
|
305
|
+
} : { target });
|
|
306
|
+
}
|
|
307
|
+
return links;
|
|
308
|
+
}
|
|
309
|
+
function stripExcludedRegions(text) {
|
|
310
|
+
return text.replace(/^---\n[\s\S]*?\n---\n?/, "").replace(/^(`{3,})[^\n]*\n[\s\S]*?\n\1\s*$/gm, "").replace(/^(~{3,})[^\n]*\n[\s\S]*?\n\1\s*$/gm, "").replace(/(\n\n)((?:(?: |\t)[^\n]*(?:\n|$))+)/g, "$1").replace(/<!--[\s\S]*?-->/g, "").replace(/``(.+?)``/g, "").replace(/`([^`]+)`/g, "");
|
|
311
|
+
}
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region src/commands/sync.ts
|
|
314
|
+
const sync = defineCommand({
|
|
315
|
+
meta: {
|
|
316
|
+
name: "sync",
|
|
317
|
+
description: "Sync wikilink graph from source"
|
|
318
|
+
},
|
|
319
|
+
args: { source: {
|
|
320
|
+
type: "string",
|
|
321
|
+
description: "source directory path",
|
|
322
|
+
alias: "s",
|
|
323
|
+
default: process.cwd()
|
|
324
|
+
} },
|
|
325
|
+
run({ args }) {
|
|
326
|
+
const sourcePath = resolve(args.source);
|
|
327
|
+
const collection = basename(sourcePath);
|
|
328
|
+
const dbPath = getCollectionDbPath(collection);
|
|
329
|
+
if (!existsSync(dbPath)) initCollection(collection, sourcePath);
|
|
330
|
+
else updateCollection(collection);
|
|
331
|
+
const db = new Database(dbPath);
|
|
332
|
+
try {
|
|
333
|
+
const store = createStore(db);
|
|
334
|
+
const changes = store.getChangedDocuments();
|
|
335
|
+
if (changes.added.length + changes.changed.length + changes.removed.length === 0) {
|
|
336
|
+
console.log("No changes detected.");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const nameIndex = buildNameIndex(store.getActiveDocumentPaths());
|
|
340
|
+
store.purgeDocuments([...changes.removed, ...changes.changed]);
|
|
341
|
+
const affected = [...changes.changed, ...changes.added];
|
|
342
|
+
const entries = store.readDocumentContents(affected).map((doc) => {
|
|
343
|
+
const links = parseWikiLinks(doc.content);
|
|
344
|
+
const targetPaths = [...new Set(links.map((link) => nameIndex.get(link.target) ?? link.target))];
|
|
345
|
+
return {
|
|
346
|
+
sourcePath: doc.path,
|
|
347
|
+
targetPaths,
|
|
348
|
+
hash: doc.hash
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
store.syncDocuments(entries);
|
|
352
|
+
console.log(`Synced: +${changes.added.length} ~${changes.changed.length} -${changes.removed.length}`);
|
|
353
|
+
} finally {
|
|
354
|
+
db.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/commands/search.ts
|
|
360
|
+
const search = defineCommand({
|
|
361
|
+
meta: {
|
|
362
|
+
name: "search",
|
|
363
|
+
description: "Search documents via ir"
|
|
364
|
+
},
|
|
365
|
+
args: {
|
|
366
|
+
query: {
|
|
367
|
+
type: "positional",
|
|
368
|
+
description: "search query",
|
|
369
|
+
required: true
|
|
370
|
+
},
|
|
371
|
+
source: {
|
|
372
|
+
type: "string",
|
|
373
|
+
description: "source directory path",
|
|
374
|
+
alias: "s",
|
|
375
|
+
default: process.cwd()
|
|
376
|
+
},
|
|
377
|
+
json: {
|
|
378
|
+
type: "boolean",
|
|
379
|
+
description: "output as JSON",
|
|
380
|
+
default: false
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
run({ args }) {
|
|
384
|
+
const collection = basename(resolve(args.source));
|
|
385
|
+
const dbPath = getCollectionDbPath(collection);
|
|
386
|
+
if (!existsSync(dbPath)) {
|
|
387
|
+
console.error(`Collection not found: ${collection}. Run 'aeira sync' first.`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
const results = searchCollection(collection, args.query);
|
|
391
|
+
if (results.length === 0) {
|
|
392
|
+
console.log("No results found.");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const database = new Database(dbPath, { readonly: true });
|
|
396
|
+
try {
|
|
397
|
+
const graph = createReadonlyStore(database).loadEdges();
|
|
398
|
+
if (args.json) {
|
|
399
|
+
const output = results.map((result) => ({
|
|
400
|
+
path: result.path,
|
|
401
|
+
title: result.title,
|
|
402
|
+
score: result.score,
|
|
403
|
+
snippet: result.snippet ?? void 0,
|
|
404
|
+
links: neighbors(graph, result.path, "outgoing")
|
|
405
|
+
}));
|
|
406
|
+
console.log(JSON.stringify(output, null, 2));
|
|
407
|
+
} else for (let index = 0; index < results.length; index++) {
|
|
408
|
+
const result = results[index];
|
|
409
|
+
const links = neighbors(graph, result.path, "outgoing");
|
|
410
|
+
console.log(`[${result.score.toFixed(2)}] ${result.path}`);
|
|
411
|
+
for (const link of links) console.log(` → ${link}`);
|
|
412
|
+
if (index < results.length - 1) console.log();
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
database.close();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/commands/graph.ts
|
|
421
|
+
function openGraph(source) {
|
|
422
|
+
const collection = basename(resolve(source));
|
|
423
|
+
const dbPath = getCollectionDbPath(collection);
|
|
424
|
+
if (!existsSync(dbPath)) {
|
|
425
|
+
console.error(`Collection not found: ${collection}. Run 'aeira sync' first.`);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
const database = new Database(dbPath, { readonly: true });
|
|
429
|
+
return {
|
|
430
|
+
database,
|
|
431
|
+
graph: createReadonlyStore(database).loadEdges()
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const validDirections = new Set([
|
|
435
|
+
"outgoing",
|
|
436
|
+
"incoming",
|
|
437
|
+
"both"
|
|
438
|
+
]);
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/cli.ts
|
|
441
|
+
runMain(defineCommand({
|
|
442
|
+
meta: {
|
|
443
|
+
name: "aeira",
|
|
444
|
+
version: "0.0.0",
|
|
445
|
+
description: "위키링크 기반 문서 관계 그래프 도구"
|
|
446
|
+
},
|
|
447
|
+
subCommands: {
|
|
448
|
+
init,
|
|
449
|
+
sync,
|
|
450
|
+
search,
|
|
451
|
+
graph: defineCommand({
|
|
452
|
+
meta: {
|
|
453
|
+
name: "graph",
|
|
454
|
+
description: "Query wikilink graph"
|
|
455
|
+
},
|
|
456
|
+
subCommands: {
|
|
457
|
+
neighbors: defineCommand({
|
|
458
|
+
meta: {
|
|
459
|
+
name: "neighbors",
|
|
460
|
+
description: "List 1-hop neighbors of a node"
|
|
461
|
+
},
|
|
462
|
+
args: {
|
|
463
|
+
node: {
|
|
464
|
+
type: "positional",
|
|
465
|
+
description: "target node",
|
|
466
|
+
required: true
|
|
467
|
+
},
|
|
468
|
+
source: {
|
|
469
|
+
type: "string",
|
|
470
|
+
description: "source directory path",
|
|
471
|
+
alias: "s",
|
|
472
|
+
default: process.cwd()
|
|
473
|
+
},
|
|
474
|
+
direction: {
|
|
475
|
+
type: "string",
|
|
476
|
+
description: "outgoing | incoming | both",
|
|
477
|
+
default: "both"
|
|
478
|
+
},
|
|
479
|
+
json: {
|
|
480
|
+
type: "boolean",
|
|
481
|
+
description: "output as JSON",
|
|
482
|
+
default: false
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
run({ args }) {
|
|
486
|
+
if (!validDirections.has(args.direction)) {
|
|
487
|
+
console.error(`Invalid direction: ${args.direction}. Must be outgoing, incoming, or both.`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
const { database, graph } = openGraph(args.source);
|
|
491
|
+
try {
|
|
492
|
+
const result = neighbors(graph, args.node, args.direction);
|
|
493
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
494
|
+
else for (const node of result) console.log(node);
|
|
495
|
+
} finally {
|
|
496
|
+
database.close();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}),
|
|
500
|
+
path: defineCommand({
|
|
501
|
+
meta: {
|
|
502
|
+
name: "path",
|
|
503
|
+
description: "Find all paths between two nodes"
|
|
504
|
+
},
|
|
505
|
+
args: {
|
|
506
|
+
from: {
|
|
507
|
+
type: "positional",
|
|
508
|
+
description: "start node",
|
|
509
|
+
required: true
|
|
510
|
+
},
|
|
511
|
+
to: {
|
|
512
|
+
type: "positional",
|
|
513
|
+
description: "end node",
|
|
514
|
+
required: true
|
|
515
|
+
},
|
|
516
|
+
source: {
|
|
517
|
+
type: "string",
|
|
518
|
+
description: "source directory path",
|
|
519
|
+
alias: "s",
|
|
520
|
+
default: process.cwd()
|
|
521
|
+
},
|
|
522
|
+
"max-paths": {
|
|
523
|
+
type: "string",
|
|
524
|
+
description: "max number of paths",
|
|
525
|
+
default: "20"
|
|
526
|
+
},
|
|
527
|
+
json: {
|
|
528
|
+
type: "boolean",
|
|
529
|
+
description: "output as JSON",
|
|
530
|
+
default: false
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
run({ args }) {
|
|
534
|
+
const { database, graph } = openGraph(args.source);
|
|
535
|
+
try {
|
|
536
|
+
const maxPaths = Number.parseInt(args["max-paths"], 10);
|
|
537
|
+
const result = findPaths(graph, args.from, args.to, maxPaths);
|
|
538
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
539
|
+
else for (const pathNodes of result) console.log(pathNodes.join(" → "));
|
|
540
|
+
} finally {
|
|
541
|
+
database.close();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}),
|
|
545
|
+
all: defineCommand({
|
|
546
|
+
meta: {
|
|
547
|
+
name: "all",
|
|
548
|
+
description: "Show entire graph"
|
|
549
|
+
},
|
|
550
|
+
args: {
|
|
551
|
+
source: {
|
|
552
|
+
type: "string",
|
|
553
|
+
description: "source directory path",
|
|
554
|
+
alias: "s",
|
|
555
|
+
default: process.cwd()
|
|
556
|
+
},
|
|
557
|
+
json: {
|
|
558
|
+
type: "boolean",
|
|
559
|
+
description: "output as JSON",
|
|
560
|
+
default: false
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
run({ args }) {
|
|
564
|
+
const { database, graph } = openGraph(args.source);
|
|
565
|
+
try {
|
|
566
|
+
const result = snapshot(graph);
|
|
567
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
568
|
+
else {
|
|
569
|
+
const nodesInEdges = new Set(result.edges.flatMap(([source, target]) => [source, target]));
|
|
570
|
+
for (const [source, target] of result.edges) console.log(`${source} → ${target}`);
|
|
571
|
+
for (const node of result.nodes) if (!nodesInEdges.has(node)) console.log(node);
|
|
572
|
+
}
|
|
573
|
+
} finally {
|
|
574
|
+
database.close();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
}
|
|
581
|
+
}));
|
|
582
|
+
//#endregion
|
|
583
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grapgrap/aeira",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "위키링크 기반 문서 관계 그래프 도구",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"bin": "./dist/cli.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsdown",
|
|
13
|
+
"type-check": "tsc --noEmit",
|
|
14
|
+
"dev": "tsdown --watch",
|
|
15
|
+
"test": "vitest",
|
|
16
|
+
"lint": "oxlint",
|
|
17
|
+
"fmt": "oxfmt --write .",
|
|
18
|
+
"fmt:check": "oxfmt --check .",
|
|
19
|
+
"prepare": "husky"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"better-sqlite3": "^11.9.1",
|
|
23
|
+
"citty": "^0.2.2",
|
|
24
|
+
"zod": "^4.3.6"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
28
|
+
"@types/node": "^22.15.2",
|
|
29
|
+
"husky": "^9.1.7",
|
|
30
|
+
"lint-staged": "^16.4.0",
|
|
31
|
+
"oxfmt": "^0.44.0",
|
|
32
|
+
"oxlint": "^1.59.0",
|
|
33
|
+
"tsdown": "^0.12.5",
|
|
34
|
+
"typescript": "^5.8.3",
|
|
35
|
+
"vitest": "^3.1.2"
|
|
36
|
+
},
|
|
37
|
+
"lint-staged": {
|
|
38
|
+
"*.{ts,js}": [
|
|
39
|
+
"oxlint --deny-warnings",
|
|
40
|
+
"oxfmt --write"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"packageManager": "yarn@4.13.0"
|
|
47
|
+
}
|