opensidian 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.
Files changed (137) hide show
  1. package/.eslintrc.json +1 -0
  2. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +35 -0
  4. package/.github/ISSUE_TEMPLATE/question.md +23 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +45 -0
  6. package/.github/README.md +5 -0
  7. package/.github/workflows/cd.yml +44 -0
  8. package/.github/workflows/ci.yml +128 -0
  9. package/.github/workflows/qa.yml +45 -0
  10. package/.planning/PROJECT.md +96 -0
  11. package/.planning/REQUIREMENTS.md +66 -0
  12. package/.planning/ROADMAP.md +129 -0
  13. package/.planning/STATE.md +47 -0
  14. package/.planning/config.json +14 -0
  15. package/CONTRIBUTING.md +232 -0
  16. package/LICENSE +21 -0
  17. package/README.md +244 -0
  18. package/dist/api/auth.d.ts +5 -0
  19. package/dist/api/auth.d.ts.map +1 -0
  20. package/dist/api/auth.js +112 -0
  21. package/dist/api/auth.js.map +1 -0
  22. package/dist/api/routes.d.ts +3 -0
  23. package/dist/api/routes.d.ts.map +1 -0
  24. package/dist/api/routes.js +119 -0
  25. package/dist/api/routes.js.map +1 -0
  26. package/dist/api/themes.d.ts +3 -0
  27. package/dist/api/themes.d.ts.map +1 -0
  28. package/dist/api/themes.js +48 -0
  29. package/dist/api/themes.js.map +1 -0
  30. package/dist/core/graph.d.ts +16 -0
  31. package/dist/core/graph.d.ts.map +1 -0
  32. package/dist/core/graph.js +115 -0
  33. package/dist/core/graph.js.map +1 -0
  34. package/dist/core/markdown.d.ts +21 -0
  35. package/dist/core/markdown.d.ts.map +1 -0
  36. package/dist/core/markdown.js +77 -0
  37. package/dist/core/markdown.js.map +1 -0
  38. package/dist/core/search.d.ts +34 -0
  39. package/dist/core/search.d.ts.map +1 -0
  40. package/dist/core/search.js +159 -0
  41. package/dist/core/search.js.map +1 -0
  42. package/dist/core/sync.d.ts +30 -0
  43. package/dist/core/sync.d.ts.map +1 -0
  44. package/dist/core/sync.js +121 -0
  45. package/dist/core/sync.js.map +1 -0
  46. package/dist/core/vault.d.ts +28 -0
  47. package/dist/core/vault.d.ts.map +1 -0
  48. package/dist/core/vault.js +235 -0
  49. package/dist/core/vault.js.map +1 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +32 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/mcp/cli.d.ts +3 -0
  55. package/dist/mcp/cli.d.ts.map +1 -0
  56. package/dist/mcp/cli.js +7 -0
  57. package/dist/mcp/cli.js.map +1 -0
  58. package/dist/mcp/server.d.ts +18 -0
  59. package/dist/mcp/server.d.ts.map +1 -0
  60. package/dist/mcp/server.js +272 -0
  61. package/dist/mcp/server.js.map +1 -0
  62. package/dist/plugins/host.d.ts +23 -0
  63. package/dist/plugins/host.d.ts.map +1 -0
  64. package/dist/plugins/host.js +104 -0
  65. package/dist/plugins/host.js.map +1 -0
  66. package/dist/plugins/sample-plugin.d.ts +10 -0
  67. package/dist/plugins/sample-plugin.d.ts.map +1 -0
  68. package/dist/plugins/sample-plugin.js +23 -0
  69. package/dist/plugins/sample-plugin.js.map +1 -0
  70. package/dist/server.d.ts +15 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +77 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/shared/types.d.ts +86 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/shared/types.js +2 -0
  77. package/dist/shared/types.js.map +1 -0
  78. package/docker/Dockerfile +37 -0
  79. package/docker/docker-compose.yml +46 -0
  80. package/docs/ARCHITECTURE.md +321 -0
  81. package/futuras_implementacoes.md +0 -0
  82. package/package.json +65 -0
  83. package/scripts/fix-gitignore.ps1 +5 -0
  84. package/scripts/seed-notes.mjs +60 -0
  85. package/src/api/auth.ts +130 -0
  86. package/src/api/routes.ts +133 -0
  87. package/src/api/themes.ts +60 -0
  88. package/src/core/graph.ts +145 -0
  89. package/src/core/markdown.ts +92 -0
  90. package/src/core/search.ts +208 -0
  91. package/src/core/sync.ts +157 -0
  92. package/src/core/vault.ts +286 -0
  93. package/src/index.ts +37 -0
  94. package/src/mcp/cli.ts +7 -0
  95. package/src/mcp/server.ts +296 -0
  96. package/src/plugins/host.ts +120 -0
  97. package/src/plugins/sample-plugin.ts +29 -0
  98. package/src/server.ts +90 -0
  99. package/src/shared/types.ts +92 -0
  100. package/tests/api/routes.test.ts +167 -0
  101. package/tests/core/graph.test.ts +236 -0
  102. package/tests/core/markdown.test.ts +157 -0
  103. package/tests/core/search.test.ts +132 -0
  104. package/tests/core/sync.test.ts +62 -0
  105. package/tests/core/vault.test.ts +162 -0
  106. package/tests/mcp/server.test.ts +118 -0
  107. package/tests/plugins/host.test.ts +165 -0
  108. package/tests/plugins/sample-plugin.test.ts +35 -0
  109. package/tests/server.test.ts +76 -0
  110. package/tsconfig.json +27 -0
  111. package/vite.config.ts +27 -0
  112. package/vitest.config.ts +33 -0
  113. package/web/index.html +13 -0
  114. package/web/package.json +26 -0
  115. package/web/public/favicon.svg +4 -0
  116. package/web/src/App.tsx +63 -0
  117. package/web/src/api/auth.ts +65 -0
  118. package/web/src/api/client.ts +117 -0
  119. package/web/src/api/themes.ts +78 -0
  120. package/web/src/components/GraphView.tsx +139 -0
  121. package/web/src/components/Layout.tsx +74 -0
  122. package/web/src/components/LoginPage.tsx +52 -0
  123. package/web/src/components/NoteEditor.tsx +114 -0
  124. package/web/src/components/NoteList.tsx +95 -0
  125. package/web/src/components/RegisterPage.tsx +58 -0
  126. package/web/src/components/SearchBar.tsx +71 -0
  127. package/web/src/components/SearchPanel.tsx +152 -0
  128. package/web/src/components/ThemeEditor.tsx +129 -0
  129. package/web/src/components/ThemeSelector.tsx +41 -0
  130. package/web/src/components/VaultList.tsx +89 -0
  131. package/web/src/hooks/AuthContext.tsx +57 -0
  132. package/web/src/hooks/ThemeContext.tsx +77 -0
  133. package/web/src/hooks/useWebSocket.ts +34 -0
  134. package/web/src/main.tsx +10 -0
  135. package/web/src/styles/global.css +449 -0
  136. package/web/tsconfig.json +21 -0
  137. package/web/vite.config.ts +19 -0
@@ -0,0 +1,167 @@
1
+ import express from 'express';
2
+ import fs from 'fs/promises';
3
+ import { createServer, type Server } from 'http';
4
+ import os from 'os';
5
+ import path from 'path';
6
+ import routes from '../../src/api/routes.js';
7
+
8
+ describe('API routes', () => {
9
+ let server: Server;
10
+ let baseUrl: string;
11
+
12
+ beforeAll(async () => {
13
+ const app = express();
14
+ app.use(express.json());
15
+ app.use('/api', routes);
16
+
17
+ server = createServer(app);
18
+
19
+ await new Promise<void>((resolve) => {
20
+ server.listen(0, '127.0.0.1', () => resolve());
21
+ });
22
+
23
+ const address = server.address();
24
+ if (!address || typeof address === 'string') {
25
+ throw new Error('Failed to start test server');
26
+ }
27
+
28
+ baseUrl = `http://127.0.0.1:${address.port}`;
29
+ });
30
+
31
+ afterAll(async () => {
32
+ await new Promise<void>((resolve, reject) => {
33
+ server.close((error) => {
34
+ if (error) {
35
+ reject(error);
36
+ return;
37
+ }
38
+ resolve();
39
+ });
40
+ });
41
+ });
42
+
43
+ it('lists vaults and validates required params', async () => {
44
+ const listResponse = await fetch(`${baseUrl}/api/vaults`);
45
+ expect(listResponse.status).toBe(200);
46
+ expect(Array.isArray((await listResponse.json()).vaults)).toBe(true);
47
+
48
+ const createResponse = await fetch(`${baseUrl}/api/vaults`, {
49
+ method: 'POST',
50
+ headers: { 'content-type': 'application/json' },
51
+ body: JSON.stringify({}),
52
+ });
53
+
54
+ expect(createResponse.status).toBe(400);
55
+ expect(await createResponse.json()).toEqual({
56
+ error: 'name and path are required',
57
+ });
58
+
59
+ const notFoundResponse = await fetch(`${baseUrl}/api/vaults/does-not-exist`);
60
+ expect(notFoundResponse.status).toBe(404);
61
+
62
+ const deleteResponse = await fetch(`${baseUrl}/api/vaults/any`, {
63
+ method: 'DELETE',
64
+ });
65
+ expect(deleteResponse.status).toBe(501);
66
+ });
67
+
68
+ it('executes the note lifecycle and graph endpoints', async () => {
69
+ const vaultPath = await fs.mkdtemp(path.join(os.tmpdir(), 'opensidian-routes-'));
70
+
71
+ const vaultResponse = await fetch(`${baseUrl}/api/vaults`, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify({ name: 'Routes Vault', path: vaultPath }),
75
+ });
76
+ expect(vaultResponse.status).toBe(201);
77
+
78
+ const missingVaultResponse = await fetch(`${baseUrl}/api/notes`);
79
+ expect(missingVaultResponse.status).toBe(400);
80
+
81
+ const secondNoteResponse = await fetch(`${baseUrl}/api/notes`, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json' },
84
+ body: JSON.stringify({
85
+ vaultPath,
86
+ filename: 'second',
87
+ content: '# Second',
88
+ }),
89
+ });
90
+ expect(secondNoteResponse.status).toBe(201);
91
+
92
+ const createNoteResponse = await fetch(`${baseUrl}/api/notes`, {
93
+ method: 'POST',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({
96
+ vaultPath,
97
+ filename: 'first',
98
+ content: '# First\n\nSee [[second]] for more.',
99
+ }),
100
+ });
101
+ expect(createNoteResponse.status).toBe(201);
102
+
103
+ const listNotesResponse = await fetch(
104
+ `${baseUrl}/api/notes?${new URLSearchParams({ vault: vaultPath }).toString()}`
105
+ );
106
+ expect(listNotesResponse.status).toBe(200);
107
+ expect((await listNotesResponse.json()).notes.length).toBeGreaterThan(0);
108
+
109
+ const readNoteResponse = await fetch(
110
+ `${baseUrl}/api/notes/first.md?${new URLSearchParams({ vault: vaultPath }).toString()}`
111
+ );
112
+ expect(readNoteResponse.status).toBe(200);
113
+ expect((await readNoteResponse.json()).note.filename).toBe('first');
114
+
115
+ const missingNoteResponse = await fetch(
116
+ `${baseUrl}/api/notes/missing.md?${new URLSearchParams({ vault: vaultPath }).toString()}`
117
+ );
118
+ expect(missingNoteResponse.status).toBe(404);
119
+
120
+ const updateMissingContentResponse = await fetch(
121
+ `${baseUrl}/api/notes/first.md?${new URLSearchParams({ vault: vaultPath }).toString()}`,
122
+ {
123
+ method: 'PUT',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({}),
126
+ }
127
+ );
128
+ expect(updateMissingContentResponse.status).toBe(400);
129
+
130
+ const updateNoteResponse = await fetch(
131
+ `${baseUrl}/api/notes/first.md?${new URLSearchParams({ vault: vaultPath }).toString()}`,
132
+ {
133
+ method: 'PUT',
134
+ headers: { 'content-type': 'application/json' },
135
+ body: JSON.stringify({ content: '# Updated\n\nStill linked to [[second]].' }),
136
+ }
137
+ );
138
+ expect(updateNoteResponse.status).toBe(200);
139
+
140
+ const graphMissingVaultResponse = await fetch(`${baseUrl}/api/graph`);
141
+ expect(graphMissingVaultResponse.status).toBe(400);
142
+
143
+ const graphResponse = await fetch(
144
+ `${baseUrl}/api/graph?${new URLSearchParams({ vault: vaultPath }).toString()}`
145
+ );
146
+ expect(graphResponse.status).toBe(200);
147
+ expect((await graphResponse.json()).graph.metadata.totalNotes).toBeGreaterThan(0);
148
+
149
+ const neighborsResponse = await fetch(
150
+ `${baseUrl}/api/graph/neighbors/first.md?${new URLSearchParams({ vault: vaultPath }).toString()}`
151
+ );
152
+ expect(neighborsResponse.status).toBe(200);
153
+ expect((await neighborsResponse.json()).neighbors).toEqual(
154
+ expect.arrayContaining([expect.objectContaining({ id: 'second.md' })])
155
+ );
156
+
157
+ const deleteNoteResponse = await fetch(
158
+ `${baseUrl}/api/notes/first.md?${new URLSearchParams({ vault: vaultPath }).toString()}`,
159
+ {
160
+ method: 'DELETE',
161
+ }
162
+ );
163
+ expect(deleteNoteResponse.status).toBe(204);
164
+
165
+ await fs.rm(vaultPath, { recursive: true, force: true });
166
+ });
167
+ });
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { GraphEngine } from '../../src/core/graph';
3
+ import { Note } from '../../src/shared/types';
4
+
5
+ describe('GraphEngine', () => {
6
+ let graphEngine: GraphEngine;
7
+ const testVaultPath = '/tmp/test-vault';
8
+
9
+ beforeEach(() => {
10
+ graphEngine = new GraphEngine();
11
+ });
12
+
13
+ describe('indexNote', () => {
14
+ it('should add node to graph when indexing note', () => {
15
+ const note: Note = {
16
+ path: 'test-note.md',
17
+ filename: 'test-note',
18
+ content: '# Test',
19
+ frontmatter: {},
20
+ links: [],
21
+ created: new Date().toISOString(),
22
+ modified: new Date().toISOString(),
23
+ };
24
+
25
+ graphEngine.indexNote(testVaultPath, note);
26
+ const graph = graphEngine.getGraph(testVaultPath);
27
+
28
+ expect(graph).toBeDefined();
29
+ expect(graph?.nodes).toHaveLength(1);
30
+ expect(graph?.nodes[0].id).toBe('test-note.md');
31
+ expect(graph?.nodes[0].label).toBe('test-note');
32
+ });
33
+
34
+ it('should extract tags from frontmatter', () => {
35
+ const note: Note = {
36
+ path: 'tagged-note.md',
37
+ filename: 'tagged-note',
38
+ content: '# Tagged',
39
+ frontmatter: { tags: ['javascript', 'programming'] },
40
+ links: [],
41
+ created: new Date().toISOString(),
42
+ modified: new Date().toISOString(),
43
+ };
44
+
45
+ graphEngine.indexNote(testVaultPath, note);
46
+ const graph = graphEngine.getGraph(testVaultPath);
47
+
48
+ expect(graph?.nodes[0].tags).toContain('javascript');
49
+ expect(graph?.nodes[0].tags).toContain('programming');
50
+ });
51
+
52
+ it('should create edges for wiki links', () => {
53
+ const note: Note = {
54
+ path: 'source.md',
55
+ filename: 'source',
56
+ content: '# Source\n\nSee [[target]] for info.',
57
+ frontmatter: {},
58
+ links: ['target'],
59
+ created: new Date().toISOString(),
60
+ modified: new Date().toISOString(),
61
+ };
62
+
63
+ graphEngine.indexNote(testVaultPath, note);
64
+ const graph = graphEngine.getGraph(testVaultPath);
65
+
66
+ expect(graph?.edges).toHaveLength(1);
67
+ expect(graph?.edges[0].source).toBe('source.md');
68
+ expect(graph?.edges[0].target).toBe('target.md');
69
+ });
70
+
71
+ it('should update existing node when re-indexing', () => {
72
+ const note1: Note = {
73
+ path: 'note.md',
74
+ filename: 'note',
75
+ content: '# Original',
76
+ frontmatter: {},
77
+ links: [],
78
+ created: new Date().toISOString(),
79
+ modified: new Date().toISOString(),
80
+ };
81
+
82
+ const note2: Note = {
83
+ ...note1,
84
+ content: '# Updated',
85
+ links: ['link1', 'link2'],
86
+ };
87
+
88
+ graphEngine.indexNote(testVaultPath, note1);
89
+ graphEngine.indexNote(testVaultPath, note2);
90
+ const graph = graphEngine.getGraph(testVaultPath);
91
+
92
+ expect(graph?.nodes).toHaveLength(1);
93
+ expect(graph?.edges).toHaveLength(2);
94
+ });
95
+
96
+ it('should update metadata after indexing', () => {
97
+ const note1: Note = createNote('note1.md', ['link1']);
98
+ const note2: Note = createNote('note2.md', ['link1', 'link2']);
99
+
100
+ graphEngine.indexNote(testVaultPath, note1);
101
+ graphEngine.indexNote(testVaultPath, note2);
102
+ const graph = graphEngine.getGraph(testVaultPath);
103
+
104
+ expect(graph?.metadata.totalNotes).toBe(2);
105
+ expect(graph?.metadata.totalLinks).toBe(3);
106
+ });
107
+ });
108
+
109
+ describe('removeNote', () => {
110
+ it('should remove node from graph', () => {
111
+ const note: Note = createNote('to-remove.md', []);
112
+ graphEngine.indexNote(testVaultPath, note);
113
+
114
+ graphEngine.removeNote(testVaultPath, 'to-remove.md');
115
+ const graph = graphEngine.getGraph(testVaultPath);
116
+
117
+ expect(graph?.nodes).toHaveLength(0);
118
+ });
119
+
120
+ it('should remove associated edges', () => {
121
+ const note1: Note = createNote('source.md', ['target.md']);
122
+ const note2: Note = createNote('target.md', []);
123
+
124
+ graphEngine.indexNote(testVaultPath, note1);
125
+ graphEngine.indexNote(testVaultPath, note2);
126
+
127
+ graphEngine.removeNote(testVaultPath, 'source.md');
128
+ const graph = graphEngine.getGraph(testVaultPath);
129
+
130
+ expect(graph?.edges).toHaveLength(0);
131
+ });
132
+ });
133
+
134
+ describe('getNeighbors', () => {
135
+ it('should return connected nodes', () => {
136
+ const note1: Note = createNote('note1.md', ['note2.md', 'note3.md']);
137
+ const note2: Note = createNote('note2.md', []);
138
+ const note3: Note = createNote('note3.md', []);
139
+
140
+ graphEngine.indexNote(testVaultPath, note1);
141
+ graphEngine.indexNote(testVaultPath, note2);
142
+ graphEngine.indexNote(testVaultPath, note3);
143
+
144
+ const neighbors = graphEngine.getNeighbors(testVaultPath, 'note1.md');
145
+
146
+ expect(neighbors).toHaveLength(2);
147
+ expect(neighbors.map(n => n.id)).toContain('note2.md');
148
+ expect(neighbors.map(n => n.id)).toContain('note3.md');
149
+ });
150
+
151
+ it('should return empty array for isolated node', () => {
152
+ const note: Note = createNote('isolated.md', []);
153
+ graphEngine.indexNote(testVaultPath, note);
154
+
155
+ const neighbors = graphEngine.getNeighbors(testVaultPath, 'isolated.md');
156
+
157
+ expect(neighbors).toHaveLength(0);
158
+ });
159
+ });
160
+
161
+ describe('getBacklinks', () => {
162
+ it('should return nodes that link to specified node', () => {
163
+ const note1: Note = createNote('note1.md', ['target.md']);
164
+ const note2: Note = createNote('note2.md', ['target.md']);
165
+ const note3: Note = createNote('note3.md', []);
166
+
167
+ graphEngine.indexNote(testVaultPath, note1);
168
+ graphEngine.indexNote(testVaultPath, note2);
169
+ graphEngine.indexNote(testVaultPath, note3);
170
+
171
+ const backlinks = graphEngine.getBacklinks(testVaultPath, 'target.md');
172
+
173
+ expect(backlinks).toHaveLength(2);
174
+ expect(backlinks.map(n => n.id)).toContain('note1.md');
175
+ expect(backlinks.map(n => n.id)).toContain('note2.md');
176
+ });
177
+ });
178
+
179
+ describe('searchByTag', () => {
180
+ it('should find nodes by tag', () => {
181
+ const note1: Note = createNote('js-note.md', [], { tags: ['javascript'] });
182
+ const note2: Note = createNote('py-note.md', [], { tags: ['python'] });
183
+ const note3: Note = createNote('both-note.md', [], { tags: ['javascript', 'python'] });
184
+
185
+ graphEngine.indexNote(testVaultPath, note1);
186
+ graphEngine.indexNote(testVaultPath, note2);
187
+ graphEngine.indexNote(testVaultPath, note3);
188
+
189
+ const results = graphEngine.searchByTag(testVaultPath, 'javascript');
190
+
191
+ expect(results).toHaveLength(2);
192
+ });
193
+ });
194
+
195
+ describe('computeStats', () => {
196
+ it('should return most connected nodes', () => {
197
+ const highlyConnected: Note = createNote('hub.md', ['n1.md', 'n2.md', 'n3.md']);
198
+ const leaf1: Note = createNote('leaf1.md', ['hub.md']);
199
+ const leaf2: Note = createNote('leaf2.md', ['hub.md']);
200
+
201
+ graphEngine.indexNote(testVaultPath, highlyConnected);
202
+ graphEngine.indexNote(testVaultPath, leaf1);
203
+ graphEngine.indexNote(testVaultPath, leaf2);
204
+
205
+ const { mostConnected } = graphEngine.computeStats(testVaultPath);
206
+
207
+ expect(mostConnected[0].id).toBe('hub.md');
208
+ });
209
+
210
+ it('should return orphan notes', () => {
211
+ const orphan: Note = createNote('orphan.md', []);
212
+ const connected: Note = createNote('connected.md', ['linked.md']);
213
+ const linked: Note = createNote('linked.md', []);
214
+
215
+ graphEngine.indexNote(testVaultPath, orphan);
216
+ graphEngine.indexNote(testVaultPath, connected);
217
+ graphEngine.indexNote(testVaultPath, linked);
218
+
219
+ const { orphanNotes } = graphEngine.computeStats(testVaultPath);
220
+
221
+ expect(orphanNotes.some(n => n.id === 'orphan.md')).toBe(true);
222
+ });
223
+ });
224
+ });
225
+
226
+ function createNote(path: string, links: string[], frontmatter: Record<string, unknown> = {}): Note {
227
+ return {
228
+ path,
229
+ filename: path.replace('.md', ''),
230
+ content: `# ${path}`,
231
+ frontmatter,
232
+ links,
233
+ created: new Date().toISOString(),
234
+ modified: new Date().toISOString(),
235
+ };
236
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { MarkdownParser } from '../../src/core/markdown';
3
+
4
+ describe('MarkdownParser', () => {
5
+ let parser: MarkdownParser;
6
+
7
+ beforeEach(() => {
8
+ parser = new MarkdownParser();
9
+ });
10
+
11
+ describe('parse', () => {
12
+ it('should parse basic markdown content', () => {
13
+ const result = parser.parse('# Hello World\n\nThis is content.');
14
+
15
+ expect(result.body).toBe('# Hello World\n\nThis is content.');
16
+ expect(result.html).toContain('<h1');
17
+ expect(result.html).toContain('Hello World');
18
+ });
19
+
20
+ it('should extract frontmatter', () => {
21
+ const content = `---
22
+ title: Test Title
23
+ author: Test Author
24
+ ---
25
+ # Content`;
26
+
27
+ const result = parser.parse<{ title: string; author: string }>(content);
28
+
29
+ expect(result.frontmatter).toHaveProperty('title', 'Test Title');
30
+ expect(result.frontmatter).toHaveProperty('author', 'Test Author');
31
+ });
32
+
33
+ it('should extract wiki links', () => {
34
+ const content = '# Note\n\nSee [[Another Note]] for details.\n\nAlso check [[Third Note|See this]].';
35
+
36
+ const result = parser.parse(content);
37
+
38
+ expect(result.links).toContain('Another Note');
39
+ expect(result.links).toContain('Third Note');
40
+ });
41
+
42
+ it('should extract markdown links', () => {
43
+ const content = '# Note\n\nCheck [this link](other-note.md) for info.';
44
+
45
+ const result = parser.parse(content);
46
+
47
+ expect(result.links).toContain('other-note');
48
+ });
49
+
50
+ it('should exclude external links', () => {
51
+ const content = '# Note\n\nVisit [Google](https://google.com) for search.';
52
+
53
+ const result = parser.parse(content);
54
+
55
+ expect(result.links).not.toContain('https://google.com');
56
+ });
57
+
58
+ it('should extract headings with IDs', () => {
59
+ const content = '# Main Title\n\n## Section One\n\n### Subsection\n\n## Section Two';
60
+
61
+ const result = parser.parse(content);
62
+
63
+ expect(result.headings).toHaveLength(4);
64
+ expect(result.headings[0]).toEqual({ level: 1, text: 'Main Title', id: 'main-title' });
65
+ expect(result.headings[1]).toEqual({ level: 2, text: 'Section One', id: 'section-one' });
66
+ });
67
+
68
+ it('should handle empty content', () => {
69
+ const result = parser.parse('');
70
+
71
+ expect(result.body).toBe('');
72
+ expect(result.html).toBe('');
73
+ expect(result.links).toEqual([]);
74
+ expect(result.headings).toEqual([]);
75
+ });
76
+
77
+ it('should handle content without frontmatter', () => {
78
+ const content = '# Just a heading\n\nSome content.';
79
+
80
+ const result = parser.parse(content);
81
+
82
+ expect(Object.keys(result.frontmatter)).toHaveLength(0);
83
+ expect(result.body).toBe(content);
84
+ });
85
+ });
86
+
87
+ describe('extractLinks', () => {
88
+ it('should extract wiki links from content', () => {
89
+ const content = 'See [[Link One]] and [[Link Two]] for more.';
90
+ const links = parser.extractLinks(content);
91
+
92
+ expect(links).toContain('Link One');
93
+ expect(links).toContain('Link Two');
94
+ });
95
+
96
+ it('should deduplicate links', () => {
97
+ const content = 'See [[Same Link]] and [[Same Link]] again.';
98
+ const links = parser.extractLinks(content);
99
+
100
+ const sameLinkCount = links.filter(l => l === 'Same Link').length;
101
+ expect(sameLinkCount).toBe(1);
102
+ });
103
+ });
104
+
105
+ describe('extractHeadings', () => {
106
+ it('should extract all heading levels', () => {
107
+ const content = `# H1\n## H2\n### H3\n#### H4\n##### H5\n###### H6`;
108
+ const headings = parser.extractHeadings(content);
109
+
110
+ expect(headings).toHaveLength(6);
111
+ expect(headings[0].level).toBe(1);
112
+ expect(headings[5].level).toBe(6);
113
+ });
114
+
115
+ it('should generate slug IDs for headings', () => {
116
+ const headings = parser.extractHeadings('# My Great Heading');
117
+
118
+ expect(headings[0].id).toBe('my-great-heading');
119
+ });
120
+ });
121
+
122
+ describe('slugify', () => {
123
+ it('should convert text to URL-friendly slug', () => {
124
+ expect(parser.slugify('Hello World')).toBe('hello-world');
125
+ expect(parser.slugify('Test Multiple Spaces')).toBe('test-multiple-spaces');
126
+ expect(parser.slugify('Special!@#Characters')).toBe('specialcharacters');
127
+ });
128
+
129
+ it('should handle international characters', () => {
130
+ expect(parser.slugify('Über uns')).toBe('uber-uns');
131
+ });
132
+ });
133
+
134
+ describe('toMarkdown', () => {
135
+ it('should convert note to markdown with frontmatter', () => {
136
+ const note = {
137
+ frontmatter: { title: 'Test', tags: 'test' },
138
+ body: '# Content\n\nSome text.',
139
+ };
140
+
141
+ const markdown = parser.toMarkdown(note);
142
+
143
+ expect(markdown).toContain('---');
144
+ expect(markdown).toContain('title: Test');
145
+ expect(markdown).toContain('# Content');
146
+ });
147
+
148
+ it('should handle note without frontmatter', () => {
149
+ const note = { body: '# Just Content' };
150
+
151
+ const markdown = parser.toMarkdown(note);
152
+
153
+ expect(markdown).toBe('# Just Content');
154
+ expect(markdown).not.toContain('---');
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { FullTextIndexer } from '../../src/core/search';
3
+
4
+ describe('FullTextIndexer', () => {
5
+ let indexer: FullTextIndexer;
6
+
7
+ beforeEach(() => {
8
+ indexer = new FullTextIndexer('./data/test-search-index');
9
+ indexer.load();
10
+ });
11
+
12
+ const makeDoc = (path: string, content: string, tags: string[] = [], backlinks = 0) => ({
13
+ path,
14
+ filename: path.replace('.md', ''),
15
+ content,
16
+ tags,
17
+ modified: new Date().toISOString(),
18
+ backlinks,
19
+ });
20
+
21
+ describe('tokenize', () => {
22
+ it('should normalize and remove stopwords', () => {
23
+ indexer.indexNote(makeDoc('test.md', 'A casa é azul e o carro é vermelho'));
24
+ const results = indexer.search('casa azul carro', [makeDoc('test.md', 'A casa é azul e o carro é vermelho')]);
25
+ expect(results.length).toBeGreaterThan(0);
26
+ });
27
+
28
+ it('should handle accented characters', () => {
29
+ indexer.indexNote(makeDoc('test.md', 'coração órgão vídeo'));
30
+ const results = indexer.search('coracao orgao video', [makeDoc('test.md', 'coração órgão vídeo')]);
31
+ expect(results.length).toBeGreaterThan(0);
32
+ });
33
+ });
34
+
35
+ describe('indexNote', () => {
36
+ it('should add document to index', () => {
37
+ indexer.indexNote(makeDoc('note1.md', 'TypeScript is great for building apps'));
38
+ const results = indexer.search('TypeScript', [makeDoc('note1.md', 'TypeScript is great for building apps')]);
39
+ expect(results).toHaveLength(1);
40
+ expect(results[0].filename).toBe('note1');
41
+ });
42
+
43
+ it('should index multiple documents', () => {
44
+ indexer.indexNote(makeDoc('a.md', 'Node.js runtime'));
45
+ indexer.indexNote(makeDoc('b.md', 'Node.js backend server'));
46
+ const results = indexer.search('Node.js', [makeDoc('a.md', 'Node.js runtime'), makeDoc('b.md', 'Node.js backend server')]);
47
+ expect(results).toHaveLength(2);
48
+ });
49
+ });
50
+
51
+ describe('removeNote', () => {
52
+ it('should remove document from index', () => {
53
+ indexer.indexNote(makeDoc('to-remove.md', 'temporary content'));
54
+ indexer.removeNote('to-remove.md');
55
+ const results = indexer.search('temporary', [makeDoc('to-remove.md', 'temporary content')]);
56
+ expect(results).toHaveLength(0);
57
+ });
58
+ });
59
+
60
+ describe('search', () => {
61
+ it('should order by relevance (TF-IDF)', () => {
62
+ indexer.indexNote(makeDoc('short.md', 'TypeScript'));
63
+ indexer.indexNote(makeDoc('long.md', 'TypeScript TypeScript TypeScript'));
64
+ const docs = [
65
+ makeDoc('short.md', 'TypeScript'),
66
+ makeDoc('long.md', 'TypeScript TypeScript TypeScript'),
67
+ ];
68
+ const results = indexer.search('TypeScript', docs);
69
+ expect(results.length).toBeGreaterThanOrEqual(2);
70
+ expect(results[0].score).toBeGreaterThanOrEqual(results[1]?.score ?? 0);
71
+ });
72
+
73
+ it('should apply tag filter', () => {
74
+ const doc = makeDoc('tagged.md', 'javascript programming', ['javascript']);
75
+ indexer.indexNote(doc);
76
+ const results = indexer.search('javascript', [doc], { tag: 'javascript' });
77
+ expect(results).toHaveLength(1);
78
+ const empty = indexer.search('javascript', [doc], { tag: 'python' });
79
+ expect(empty).toHaveLength(0);
80
+ });
81
+
82
+ it('should apply date filter', () => {
83
+ const doc = makeDoc('old.md', 'content');
84
+ doc.modified = '2024-01-01T00:00:00.000Z';
85
+ indexer.indexNote(doc);
86
+ const resultsAfter = indexer.search('content', [doc], { after: '2025-01-01' });
87
+ expect(resultsAfter).toHaveLength(0);
88
+ const resultsBefore = indexer.search('content', [doc], { before: '2024-06-01' });
89
+ expect(resultsBefore).toHaveLength(1);
90
+ });
91
+
92
+ it('should apply backlinks filter', () => {
93
+ const withLinks = makeDoc('linked.md', 'content', [], 5);
94
+ const isolated = makeDoc('alone.md', 'content', [], 0);
95
+ indexer.indexNote(withLinks);
96
+ indexer.indexNote(isolated);
97
+ const linked = indexer.search('content', [withLinks, isolated], { hasBacklinks: true });
98
+ expect(linked).toHaveLength(1);
99
+ expect(linked[0].path).toBe('linked.md');
100
+ const alone = indexer.search('content', [withLinks, isolated], { hasBacklinks: false });
101
+ expect(alone).toHaveLength(1);
102
+ expect(alone[0].path).toBe('alone.md');
103
+ });
104
+
105
+ it('should include highlighted snippet', () => {
106
+ indexer.indexNote(makeDoc('snippet.md', 'This is the most important text in the entire document'));
107
+ const results = indexer.search('important', [makeDoc('snippet.md', 'This is the most important text in the entire document')]);
108
+ expect(results[0].snippet).toContain('<mark>');
109
+ expect(results[0].snippet).toContain('</mark>');
110
+ });
111
+
112
+ it('should return empty array for no matches', () => {
113
+ indexer.indexNote(makeDoc('note.md', 'some random words'));
114
+ const results = indexer.search('nonexistent', [makeDoc('note.md', 'some random words')]);
115
+ expect(results).toHaveLength(0);
116
+ });
117
+ });
118
+
119
+ describe('persist and load', () => {
120
+ it('should persist and reload index', async () => {
121
+ indexer.indexNote(makeDoc('persist.md', 'persistent content'));
122
+ await indexer.save();
123
+
124
+ const loaded = new FullTextIndexer('./data/test-search-index');
125
+ await loaded.load();
126
+
127
+ const results = loaded.search('persistent', [makeDoc('persist.md', 'persistent content')]);
128
+ expect(results).toHaveLength(1);
129
+ expect(results[0].filename).toBe('persist');
130
+ });
131
+ });
132
+ });