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,133 @@
1
+ import express, { Request, Response, NextFunction } from 'express';
2
+ import { VaultManager } from '../core/vault.js';
3
+ import { GraphEngine } from '../core/graph.js';
4
+
5
+ const router = express.Router();
6
+ const vaultManager = new VaultManager();
7
+ const graphEngine = new GraphEngine();
8
+
9
+ router.get('/vaults', (_req: Request, res: Response) => {
10
+ const vaults = vaultManager.listVaults();
11
+ return res.json({ vaults });
12
+ });
13
+
14
+ router.post('/vaults', (req: Request, res: Response) => {
15
+ const { name, path } = req.body;
16
+ if (!name || !path) {
17
+ return res.status(400).json({ error: 'name and path are required' });
18
+ }
19
+ const vault = vaultManager.createVault(name, path);
20
+ return res.status(201).json({ vault });
21
+ });
22
+
23
+ router.get('/vaults/:id', (req: Request, res: Response) => {
24
+ const vault = vaultManager.openVault(req.params.id);
25
+ if (!vault) {
26
+ return res.status(404).json({ error: 'Vault not found' });
27
+ }
28
+ return res.json({ vault });
29
+ });
30
+
31
+ router.delete('/vaults/:id', (_req: Request, res: Response) => {
32
+ return res.status(501).json({ error: 'Not implemented' });
33
+ });
34
+
35
+ router.get('/notes', (req: Request, res: Response) => {
36
+ const vaultPath = req.query.vault as string;
37
+ if (!vaultPath) {
38
+ return res.status(400).json({ error: 'vault query parameter is required' });
39
+ }
40
+ const vault = vaultManager.openVault(vaultPath);
41
+ if (!vault) {
42
+ return res.status(404).json({ error: 'Vault not found' });
43
+ }
44
+ return res.json({ notes: vault.notes });
45
+ });
46
+
47
+ router.post('/notes', async (req: Request, res: Response) => {
48
+ const { vaultPath, filename, content } = req.body;
49
+ if (!vaultPath || !filename || !content) {
50
+ return res.status(400).json({ error: 'vaultPath, filename, and content are required' });
51
+ }
52
+ const note = await vaultManager.createNote(vaultPath, filename, content);
53
+ graphEngine.indexNote(vaultPath, note);
54
+ return res.status(201).json({ note });
55
+ });
56
+
57
+ router.get('/notes/search', async (req: Request, res: Response) => {
58
+ const vaultPath = req.query.vault as string;
59
+ const q = req.query.q as string;
60
+ if (!vaultPath || !q) {
61
+ return res.status(400).json({ error: 'vault and q parameters are required' });
62
+ }
63
+ const tag = req.query.tag as string | undefined;
64
+ const after = req.query.after as string | undefined;
65
+ const before = req.query.before as string | undefined;
66
+ const hasBacklinks = req.query.hasBacklinks === 'true' ? true : req.query.hasBacklinks === 'false' ? false : undefined;
67
+ const results = await vaultManager.fullTextSearch(vaultPath, q, { tag, after, before, hasBacklinks });
68
+ return res.json({ results });
69
+ });
70
+
71
+ router.get('/notes/:path(*)', async (req: Request, res: Response) => {
72
+ const vaultPath = req.query.vault as string;
73
+ if (!vaultPath) {
74
+ return res.status(400).json({ error: 'vault query parameter is required' });
75
+ }
76
+ const note = await vaultManager.readNote(vaultPath, req.params.path);
77
+ if (!note) {
78
+ return res.status(404).json({ error: 'Note not found' });
79
+ }
80
+ return res.json({ note });
81
+ });
82
+
83
+ router.put('/notes/:path(*)', async (req: Request, res: Response) => {
84
+ const vaultPath = req.query.vault as string;
85
+ const { content } = req.body;
86
+ if (!vaultPath) {
87
+ return res.status(400).json({ error: 'vault query parameter is required' });
88
+ }
89
+ if (!content) {
90
+ return res.status(400).json({ error: 'content is required' });
91
+ }
92
+ const note = await vaultManager.updateNote(vaultPath, req.params.path, content);
93
+ graphEngine.indexNote(vaultPath, note);
94
+ return res.json({ note });
95
+ });
96
+
97
+ router.delete('/notes/:path(*)', async (req: Request, res: Response) => {
98
+ const vaultPath = req.query.vault as string;
99
+ if (!vaultPath) {
100
+ return res.status(400).json({ error: 'vault query parameter is required' });
101
+ }
102
+ await vaultManager.deleteNote(vaultPath, req.params.path);
103
+ graphEngine.removeNote(vaultPath, req.params.path);
104
+ return res.status(204).send();
105
+ });
106
+
107
+ router.get('/graph', (req: Request, res: Response) => {
108
+ const vaultPath = req.query.vault as string;
109
+ if (!vaultPath) {
110
+ return res.status(400).json({ error: 'vault query parameter is required' });
111
+ }
112
+ const graph = graphEngine.getGraph(vaultPath);
113
+ if (!graph) {
114
+ return res.status(404).json({ error: 'Graph not found' });
115
+ }
116
+ return res.json({ graph });
117
+ });
118
+
119
+ router.get('/graph/neighbors/:path(*)', (req: Request, res: Response) => {
120
+ const vaultPath = req.query.vault as string;
121
+ if (!vaultPath) {
122
+ return res.status(400).json({ error: 'vault query parameter is required' });
123
+ }
124
+ const neighbors = graphEngine.getNeighbors(vaultPath, req.params.path);
125
+ return res.json({ neighbors });
126
+ });
127
+
128
+ router.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
129
+ console.error('API Error:', err);
130
+ return res.status(500).json({ error: err.message || 'Internal server error' });
131
+ });
132
+
133
+ export default router;
@@ -0,0 +1,60 @@
1
+ import express, { Request, Response } from 'express';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+
5
+ const THEMES_DIR = path.resolve(process.cwd(), 'data', 'themes');
6
+
7
+ interface CustomTheme {
8
+ name: string;
9
+ variables: Record<string, string>;
10
+ }
11
+
12
+ async function ensureDir() {
13
+ await fs.mkdir(THEMES_DIR, { recursive: true });
14
+ }
15
+
16
+ const router = express.Router();
17
+
18
+ router.get('/', async (_req: Request, res: Response) => {
19
+ try {
20
+ await ensureDir();
21
+ const files = await fs.readdir(THEMES_DIR);
22
+ const themes: CustomTheme[] = [];
23
+ for (const f of files) {
24
+ if (f.endsWith('.json')) {
25
+ const data = await fs.readFile(path.join(THEMES_DIR, f), 'utf-8');
26
+ themes.push(JSON.parse(data));
27
+ }
28
+ }
29
+ res.json({ themes });
30
+ } catch {
31
+ res.json({ themes: [] });
32
+ }
33
+ });
34
+
35
+ router.post('/', async (req: Request, res: Response) => {
36
+ const { name, variables } = req.body as CustomTheme;
37
+ if (!name || !variables) {
38
+ res.status(400).json({ error: 'name e variables são obrigatórios' });
39
+ return;
40
+ }
41
+ await ensureDir();
42
+ const filename = name.toLowerCase().replace(/\s+/g, '-') + '.json';
43
+ await fs.writeFile(
44
+ path.join(THEMES_DIR, filename),
45
+ JSON.stringify({ name, variables }, null, 2)
46
+ );
47
+ res.status(201).json({ theme: { name, variables } });
48
+ });
49
+
50
+ router.delete('/:name', async (req: Request, res: Response) => {
51
+ try {
52
+ const filename = req.params.name.toLowerCase().replace(/\s+/g, '-') + '.json';
53
+ await fs.unlink(path.join(THEMES_DIR, filename));
54
+ res.status(204).send();
55
+ } catch {
56
+ res.status(404).json({ error: 'Tema não encontrado' });
57
+ }
58
+ });
59
+
60
+ export default router;
@@ -0,0 +1,145 @@
1
+ import { Note, Graph, GraphNode } from '../shared/types.js';
2
+
3
+ export class GraphEngine {
4
+ private graphs: Map<string, Graph> = new Map();
5
+
6
+ indexNote(vaultPath: string, note: Note): void {
7
+ let graph = this.graphs.get(vaultPath);
8
+
9
+ if (!graph) {
10
+ graph = { nodes: [], edges: [], metadata: { totalNotes: 0, totalLinks: 0 } };
11
+ this.graphs.set(vaultPath, graph);
12
+ }
13
+
14
+ const existingNodeIndex = graph.nodes.findIndex(n => n.id === note.path);
15
+ const node: GraphNode = {
16
+ id: note.path,
17
+ label: note.filename,
18
+ tags: this.extractTags(note.frontmatter),
19
+ connections: note.links.length,
20
+ };
21
+
22
+ if (existingNodeIndex >= 0) {
23
+ graph.nodes[existingNodeIndex] = node;
24
+ } else {
25
+ graph.nodes.push(node);
26
+ }
27
+
28
+ const edgeSet = new Set(graph.edges.map(e => `${e.source}-${e.target}`));
29
+
30
+ for (const link of note.links) {
31
+ const targetPath = link.endsWith('.md') ? link : `${link}.md`;
32
+ const edgeId = `${note.path}-${targetPath}`;
33
+ if (!edgeSet.has(edgeId)) {
34
+ graph.edges.push({
35
+ id: edgeId,
36
+ source: note.path,
37
+ target: targetPath,
38
+ type: 'wikilink',
39
+ });
40
+ edgeSet.add(edgeId);
41
+ }
42
+ }
43
+
44
+ graph.metadata.totalNotes = graph.nodes.length;
45
+ graph.metadata.totalLinks = graph.edges.length;
46
+ }
47
+
48
+ removeNote(vaultPath: string, notePath: string): void {
49
+ const graph = this.graphs.get(vaultPath);
50
+
51
+ if (!graph) return;
52
+
53
+ graph.nodes = graph.nodes.filter(n => n.id !== notePath);
54
+ graph.edges = graph.edges.filter(e => e.source !== notePath && e.target !== notePath);
55
+
56
+ graph.metadata.totalNotes = graph.nodes.length;
57
+ graph.metadata.totalLinks = graph.edges.length;
58
+ }
59
+
60
+ getGraph(vaultPath: string): Graph | null {
61
+ return this.graphs.get(vaultPath) || null;
62
+ }
63
+
64
+ getNeighbors(vaultPath: string, notePath: string): GraphNode[] {
65
+ const graph = this.graphs.get(vaultPath);
66
+
67
+ if (!graph) return [];
68
+
69
+ const neighborIds = new Set<string>();
70
+
71
+ for (const edge of graph.edges) {
72
+ if (edge.source === notePath) {
73
+ neighborIds.add(edge.target);
74
+ }
75
+ if (edge.target === notePath) {
76
+ neighborIds.add(edge.source);
77
+ }
78
+ }
79
+
80
+ return graph.nodes.filter(n => neighborIds.has(n.id));
81
+ }
82
+
83
+ getBacklinks(vaultPath: string, notePath: string): GraphNode[] {
84
+ const graph = this.graphs.get(vaultPath);
85
+
86
+ if (!graph) return [];
87
+
88
+ return graph.nodes.filter(n => {
89
+ return graph.edges.some(e => e.target === notePath && e.source === n.id);
90
+ });
91
+ }
92
+
93
+ searchByTag(vaultPath: string, tag: string): GraphNode[] {
94
+ const graph = this.graphs.get(vaultPath);
95
+
96
+ if (!graph) return [];
97
+
98
+ return graph.nodes.filter(n => n.tags?.includes(tag));
99
+ }
100
+
101
+ private extractTags(frontmatter: Record<string, unknown> | undefined): string[] {
102
+ if (!frontmatter) return [];
103
+
104
+ const tags = frontmatter.tags;
105
+ if (!tags) return [];
106
+
107
+ if (Array.isArray(tags)) {
108
+ return tags.map(t => String(t));
109
+ }
110
+
111
+ if (typeof tags === 'string') {
112
+ return tags.split(',').map(t => t.trim());
113
+ }
114
+
115
+ return [];
116
+ }
117
+
118
+ computeStats(vaultPath: string): { mostConnected: GraphNode[], orphanNotes: GraphNode[] } {
119
+ const graph = this.graphs.get(vaultPath);
120
+
121
+ if (!graph) {
122
+ return { mostConnected: [], orphanNotes: [] };
123
+ }
124
+
125
+ const connectionCounts = new Map<string, number>();
126
+
127
+ for (const edge of graph.edges) {
128
+ connectionCounts.set(edge.source, (connectionCounts.get(edge.source) || 0) + 1);
129
+ connectionCounts.set(edge.target, (connectionCounts.get(edge.target) || 0) + 1);
130
+ }
131
+
132
+ const nodesWithCount = graph.nodes.map(n => ({
133
+ ...n,
134
+ connectionCount: connectionCounts.get(n.id) || 0,
135
+ }));
136
+
137
+ const mostConnected = nodesWithCount
138
+ .sort((a, b) => b.connectionCount - a.connectionCount)
139
+ .slice(0, 10);
140
+
141
+ const orphanNotes = nodesWithCount.filter(n => n.connectionCount === 0);
142
+
143
+ return { mostConnected, orphanNotes };
144
+ }
145
+ }
@@ -0,0 +1,92 @@
1
+ import { marked } from 'marked';
2
+ import fm from 'front-matter';
3
+ import { ParsedNote } from '../shared/types.js';
4
+
5
+ export class MarkdownParser {
6
+ constructor() {
7
+ marked.setOptions({
8
+ gfm: true,
9
+ breaks: true,
10
+ });
11
+ }
12
+
13
+ parse<T extends Record<string, unknown> = Record<string, unknown>>(content: string): ParsedNote<T> {
14
+ const result = fm<T>(content);
15
+
16
+ return {
17
+ frontmatter: result.attributes ?? ({} as T),
18
+ body: result.body,
19
+ html: marked.parse(result.body) as string,
20
+ links: this.extractLinks(result.body),
21
+ headings: this.extractHeadings(result.body),
22
+ };
23
+ }
24
+
25
+ parseContent(content: string): { frontmatter: Record<string, unknown>, body: string } {
26
+ const result = fm<Record<string, unknown>>(content);
27
+ return {
28
+ frontmatter: result.attributes || {},
29
+ body: result.body,
30
+ };
31
+ }
32
+
33
+ extractLinks(content: string): string[] {
34
+ const links: string[] = [];
35
+
36
+ const wikiLinkRegex = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
37
+ const mdLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
38
+
39
+ let match;
40
+ while ((match = wikiLinkRegex.exec(content)) !== null) {
41
+ links.push(match[1]);
42
+ }
43
+ while ((match = mdLinkRegex.exec(content)) !== null) {
44
+ if (!match[2].startsWith('http')) {
45
+ links.push(match[2].replace(/\.md$/, ''));
46
+ }
47
+ }
48
+
49
+ return [...new Set(links)];
50
+ }
51
+
52
+ extractHeadings(content: string): Array<{ level: number, text: string, id: string }> {
53
+ const headings: Array<{ level: number, text: string, id: string }> = [];
54
+ const headingRegex = /^(#{1,6})\s+(.+)$/gm;
55
+
56
+ let match;
57
+ while ((match = headingRegex.exec(content)) !== null) {
58
+ const level = match[1].length;
59
+ const text = match[2].trim();
60
+ const id = this.slugify(text);
61
+ headings.push({ level, text, id });
62
+ }
63
+
64
+ return headings;
65
+ }
66
+
67
+ slugify(text: string): string {
68
+ return text
69
+ .normalize('NFKD')
70
+ .replace(/[\u0300-\u036f]/g, '')
71
+ .toLowerCase()
72
+ .replace(/[^\w\s-]/g, '')
73
+ .replace(/\s+/g, '-')
74
+ .replace(/-+/g, '-')
75
+ .trim();
76
+ }
77
+
78
+ toMarkdown(note: { frontmatter?: Record<string, unknown>, body: string }): string {
79
+ let result = '';
80
+
81
+ if (note.frontmatter && Object.keys(note.frontmatter).length > 0) {
82
+ result += '---\n';
83
+ for (const [key, value] of Object.entries(note.frontmatter)) {
84
+ result += `${key}: ${value}\n`;
85
+ }
86
+ result += '---\n\n';
87
+ }
88
+
89
+ result += note.body;
90
+ return result;
91
+ }
92
+ }
@@ -0,0 +1,208 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { existsSync } from 'fs';
4
+
5
+ interface InvertedIndex {
6
+ [term: string]: {
7
+ [notePath: string]: number;
8
+ };
9
+ }
10
+
11
+ export interface SearchDoc {
12
+ path: string;
13
+ filename: string;
14
+ content: string;
15
+ tags: string[];
16
+ modified: string;
17
+ backlinks: number;
18
+ }
19
+
20
+ export interface SearchResult {
21
+ path: string;
22
+ filename: string;
23
+ snippet: string;
24
+ score: number;
25
+ tags: string[];
26
+ modified: string;
27
+ backlinks: number;
28
+ }
29
+
30
+ const STOPWORDS = new Set([
31
+ 'de', 'da', 'do', 'das', 'dos', 'a', 'o', 'e', 'em', 'para', 'com',
32
+ 'um', 'uma', 'uns', 'umas', 'no', 'na', 'nos', 'nas', 'ao', 'aos',
33
+ 'à', 'às', 'pelo', 'pela', 'pelos', 'pelas', 'que', 'se', 'por',
34
+ 'como', 'mais', 'mas', 'ou', 'entre', 'sem', 'sua', 'seu', 'seus',
35
+ 'suas', 'meu', 'minha', 'teu', 'tuas', 'nosso', 'nossa', 'isto',
36
+ 'isso', 'aquilo', 'este', 'esta', 'esse', 'essa', 'aquele', 'aquela',
37
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can',
38
+ 'had', 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'been',
39
+ 'some', 'them', 'than', 'that', 'this', 'very', 'were', 'will',
40
+ 'with', 'from', 'they', 'what', 'when', 'where', 'which', 'who',
41
+ 'how', 'its', 'also', 'into', 'over', 'then', 'many', 'each',
42
+ 'would', 'could', 'should', 'about', 'there', 'their', 'other',
43
+ ]);
44
+
45
+ const PUNCTUATION = /[^\w\s]/g;
46
+ const WHITESPACE = /\s+/g;
47
+
48
+ function normalize(text: string): string {
49
+ return text
50
+ .normalize('NFD')
51
+ .replace(/[\u0300-\u036f]/g, '')
52
+ .toLowerCase();
53
+ }
54
+
55
+ function tokenize(text: string): string[] {
56
+ const cleaned = normalize(text).replace(PUNCTUATION, ' ').replace(WHITESPACE, ' ');
57
+ return cleaned.split(' ').filter(t => t.length > 1 && !STOPWORDS.has(t));
58
+ }
59
+
60
+ function highlightSnippet(text: string, queryTerms: string[], maxLen = 160): string {
61
+ const lower = normalize(text);
62
+ let bestIdx = 0;
63
+ let bestScore = 0;
64
+
65
+ for (const term of queryTerms) {
66
+ const idx = lower.indexOf(term);
67
+ if (idx >= 0) {
68
+ const proximity = queryTerms.reduce(
69
+ (acc, t) => acc + (lower.indexOf(t, Math.max(0, idx - 40)) >= 0 ? 1 : 0),
70
+ 0
71
+ );
72
+ if (proximity > bestScore) {
73
+ bestScore = proximity;
74
+ bestIdx = idx;
75
+ }
76
+ }
77
+ }
78
+
79
+ let start = Math.max(0, bestIdx - 60);
80
+ let end = Math.min(text.length, start + maxLen);
81
+ if (start > 0) start = text.indexOf(' ', start - 20) + 1 || start;
82
+ if (end < text.length) end = text.lastIndexOf(' ', end) || end;
83
+
84
+ let snippet = text.slice(start, end);
85
+ for (const term of queryTerms) {
86
+ const regex = new RegExp(`(${escapeRegex(term)})`, 'gi');
87
+ snippet = snippet.replace(regex, '<mark>$1</mark>');
88
+ }
89
+ return snippet;
90
+ }
91
+
92
+ function escapeRegex(str: string): string {
93
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+ }
95
+
96
+ function computeTF(terms: string[]): Map<string, number> {
97
+ const freq = new Map<string, number>();
98
+ for (const t of terms) freq.set(t, (freq.get(t) || 0) + 1);
99
+ const maxFreq = Math.max(...freq.values(), 1);
100
+ for (const [k, v] of freq) freq.set(k, v / maxFreq);
101
+ return freq;
102
+ }
103
+
104
+ export class FullTextIndexer {
105
+ private index: InvertedIndex = {};
106
+ private docCount = 0;
107
+ private indexDir: string;
108
+
109
+ constructor(indexDir = './data/search-index') {
110
+ this.indexDir = indexDir;
111
+ }
112
+
113
+ async load(): Promise<void> {
114
+ try {
115
+ const filePath = path.resolve(this.indexDir, 'index.json');
116
+ if (!existsSync(filePath)) return;
117
+ const data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
118
+ this.index = data.index || {};
119
+ this.docCount = data.docCount || 0;
120
+ } catch {
121
+ this.index = {};
122
+ this.docCount = 0;
123
+ }
124
+ }
125
+
126
+ async save(): Promise<void> {
127
+ await fs.mkdir(this.indexDir, { recursive: true });
128
+ await fs.writeFile(
129
+ path.join(this.indexDir, 'index.json'),
130
+ JSON.stringify({ index: this.index, docCount: this.docCount }, null, 2)
131
+ );
132
+ }
133
+
134
+ indexNote(note: SearchDoc): void {
135
+ const text = `${note.filename} ${note.content} ${(note.tags || []).join(' ')}`;
136
+ const terms = tokenize(text);
137
+ const tf = computeTF(terms);
138
+
139
+ for (const [term, freq] of tf) {
140
+ if (!this.index[term]) this.index[term] = {};
141
+ this.index[term][note.path] = freq;
142
+ }
143
+ this.docCount = Object.keys(
144
+ Object.values(this.index).reduce(
145
+ (acc, t) => { Object.keys(t).forEach(k => { (acc as Record<string, boolean>)[k] = true; }); return acc; },
146
+ {} as Record<string, boolean>
147
+ )
148
+ ).length;
149
+ }
150
+
151
+ removeNote(notePath: string): void {
152
+ for (const term of Object.keys(this.index)) {
153
+ delete this.index[term][notePath];
154
+ if (Object.keys(this.index[term]).length === 0) delete this.index[term];
155
+ }
156
+ }
157
+
158
+ search(query: string, allNotes: SearchDoc[], filters?: {
159
+ tag?: string;
160
+ after?: string;
161
+ before?: string;
162
+ hasBacklinks?: boolean;
163
+ }): SearchResult[] {
164
+ const queryTerms = tokenize(query);
165
+ if (queryTerms.length === 0) return [];
166
+
167
+ const idf = (term: string): number => {
168
+ const df = Object.keys(this.index[term] || {}).length;
169
+ return df > 0 ? Math.log(1 + (this.docCount - df + 0.5) / (df + 0.5)) + 1 : 0;
170
+ };
171
+
172
+ const docScores = new Map<string, number>();
173
+ for (const term of queryTerms) {
174
+ const postings = this.index[term];
175
+ if (!postings) continue;
176
+ const weight = idf(term);
177
+ for (const [docPath, tf] of Object.entries(postings)) {
178
+ docScores.set(docPath, (docScores.get(docPath) || 0) + tf * weight);
179
+ }
180
+ }
181
+
182
+ const noteMap = new Map(allNotes.map(n => [n.path, n]));
183
+
184
+ const results: SearchResult[] = [];
185
+ for (const [docPath, score] of docScores) {
186
+ const note = noteMap.get(docPath);
187
+ if (!note) continue;
188
+
189
+ if (filters?.tag && !note.tags.includes(filters.tag)) continue;
190
+ if (filters?.after && note.modified < filters.after) continue;
191
+ if (filters?.before && note.modified > filters.before) continue;
192
+ if (filters?.hasBacklinks === true && note.backlinks === 0) continue;
193
+ if (filters?.hasBacklinks === false && note.backlinks > 0) continue;
194
+
195
+ results.push({
196
+ path: note.path,
197
+ filename: note.filename,
198
+ snippet: highlightSnippet(note.content, queryTerms),
199
+ score,
200
+ tags: note.tags,
201
+ modified: note.modified,
202
+ backlinks: note.backlinks,
203
+ });
204
+ }
205
+
206
+ return results.sort((a, b) => b.score - a.score);
207
+ }
208
+ }