markdown-notes-engine 1.0.2 → 2.0.1

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.
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Version Control Client
3
+ * Git-like version control system backed by PostgreSQL
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+
8
+ export class VersionControlClient {
9
+ constructor(db, branch = 'main', author = 'user') {
10
+ this.db = db;
11
+ this.branch = branch;
12
+ this.author = author;
13
+ }
14
+
15
+ /**
16
+ * Hash content using SHA-256
17
+ * @private
18
+ * @param {string} content - Content to hash
19
+ * @returns {string} SHA-256 hash
20
+ */
21
+ _hashContent(content) {
22
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
23
+ }
24
+
25
+ /**
26
+ * Get the current commit for the branch
27
+ * @private
28
+ * @returns {Promise<string|null>} Commit ID or null
29
+ */
30
+ async _getCurrentCommit() {
31
+ const result = await this.db.query(
32
+ 'SELECT commit_id FROM branches WHERE name = $1',
33
+ [this.branch]
34
+ );
35
+ return result.rows.length > 0 ? result.rows[0].commit_id : null;
36
+ }
37
+
38
+ /**
39
+ * Get all files in the current branch's latest commit
40
+ * @private
41
+ * @returns {Promise<Array>} Array of {path, blob_hash}
42
+ */
43
+ async _getCurrentFiles() {
44
+ const commitId = await this._getCurrentCommit();
45
+ if (!commitId) return [];
46
+
47
+ const result = await this.db.query(
48
+ 'SELECT path, blob_hash FROM trees WHERE commit_id = $1',
49
+ [commitId]
50
+ );
51
+
52
+ return result.rows;
53
+ }
54
+
55
+ /**
56
+ * Create a new commit with the given file tree
57
+ * @private
58
+ * @param {Array} files - Array of {path, blob_hash}
59
+ * @param {string} message - Commit message
60
+ * @returns {Promise<string>} New commit ID
61
+ */
62
+ async _createCommit(files, message) {
63
+ const client = await this.db.getClient();
64
+
65
+ try {
66
+ await client.query('BEGIN');
67
+
68
+ const parentId = await this._getCurrentCommit();
69
+
70
+ // Create commit
71
+ const commitResult = await client.query(
72
+ `INSERT INTO commits (parent_id, message, author)
73
+ VALUES ($1, $2, $3)
74
+ RETURNING id`,
75
+ [parentId, message, this.author]
76
+ );
77
+
78
+ const commitId = commitResult.rows[0].id;
79
+
80
+ // Insert tree entries
81
+ for (const file of files) {
82
+ await client.query(
83
+ `INSERT INTO trees (commit_id, path, blob_hash)
84
+ VALUES ($1, $2, $3)`,
85
+ [commitId, file.path, file.blob_hash]
86
+ );
87
+ }
88
+
89
+ // Update branch pointer
90
+ await client.query(
91
+ `UPDATE branches SET commit_id = $1, updated_at = NOW()
92
+ WHERE name = $2`,
93
+ [commitId, this.branch]
94
+ );
95
+
96
+ await client.query('COMMIT');
97
+ return commitId;
98
+ } catch (error) {
99
+ await client.query('ROLLBACK');
100
+ throw error;
101
+ } finally {
102
+ client.release();
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Store blob if it doesn't exist
108
+ * @private
109
+ * @param {string} content - Blob content
110
+ * @returns {Promise<string>} Blob hash
111
+ */
112
+ async _storeBlob(content) {
113
+ const hash = this._hashContent(content);
114
+
115
+ // Insert blob if it doesn't exist (upsert)
116
+ await this.db.query(
117
+ `INSERT INTO blobs (hash, content, size)
118
+ VALUES ($1, $2, $3)
119
+ ON CONFLICT (hash) DO NOTHING`,
120
+ [hash, content, Buffer.byteLength(content, 'utf8')]
121
+ );
122
+
123
+ return hash;
124
+ }
125
+
126
+ /**
127
+ * Get blob content by hash
128
+ * @private
129
+ * @param {string} hash - Blob hash
130
+ * @returns {Promise<string|null>} Blob content
131
+ */
132
+ async _getBlob(hash) {
133
+ const result = await this.db.query(
134
+ 'SELECT content FROM blobs WHERE hash = $1',
135
+ [hash]
136
+ );
137
+ return result.rows.length > 0 ? result.rows[0].content : null;
138
+ }
139
+
140
+ /**
141
+ * Update search index for a blob
142
+ * @private
143
+ * @param {string} hash - Blob hash
144
+ * @param {string} content - Blob content
145
+ */
146
+ async _updateSearchIndex(hash, content) {
147
+ await this.db.query(
148
+ `INSERT INTO search_index (blob_hash, content_tsvector)
149
+ VALUES ($1, to_tsvector('english', $2))
150
+ ON CONFLICT (blob_hash)
151
+ DO UPDATE SET
152
+ content_tsvector = to_tsvector('english', $2),
153
+ updated_at = NOW()`,
154
+ [hash, content]
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Build hierarchical file structure from flat file list
160
+ * @private
161
+ * @param {Array} files - Array of {path, blob_hash}
162
+ * @returns {Array} Hierarchical structure
163
+ */
164
+ _buildStructureFromFiles(files) {
165
+ const root = [];
166
+ const folderMap = new Map();
167
+
168
+ // Sort files for consistent ordering
169
+ files.sort((a, b) => a.path.localeCompare(b.path));
170
+
171
+ for (const file of files) {
172
+ const parts = file.path.split('/');
173
+ let currentLevel = root;
174
+ let currentPath = '';
175
+
176
+ for (let i = 0; i < parts.length; i++) {
177
+ const part = parts[i];
178
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
179
+
180
+ if (i === parts.length - 1) {
181
+ // It's a file
182
+ currentLevel.push({
183
+ name: part,
184
+ type: 'file',
185
+ path: file.path,
186
+ sha: file.blob_hash
187
+ });
188
+ } else {
189
+ // It's a folder
190
+ let folder = folderMap.get(currentPath);
191
+
192
+ if (!folder) {
193
+ folder = {
194
+ name: part,
195
+ type: 'folder',
196
+ path: currentPath,
197
+ children: []
198
+ };
199
+ folderMap.set(currentPath, folder);
200
+ currentLevel.push(folder);
201
+ }
202
+
203
+ currentLevel = folder.children;
204
+ }
205
+ }
206
+ }
207
+
208
+ // Sort: folders first, then alphabetically
209
+ const sortStructure = (items) => {
210
+ items.sort((a, b) => {
211
+ if (a.type === b.type) return a.name.localeCompare(b.name);
212
+ return a.type === 'folder' ? -1 : 1;
213
+ });
214
+
215
+ items.forEach((item) => {
216
+ if (item.type === 'folder' && item.children) {
217
+ sortStructure(item.children);
218
+ }
219
+ });
220
+ };
221
+
222
+ sortStructure(root);
223
+ return root;
224
+ }
225
+
226
+ /**
227
+ * Get file structure for the current branch
228
+ * @returns {Promise<Array>} Hierarchical file structure
229
+ */
230
+ async getFileStructure() {
231
+ const files = await this._getCurrentFiles();
232
+ return this._buildStructureFromFiles(files);
233
+ }
234
+
235
+ /**
236
+ * Get file content and metadata
237
+ * @param {string} path - File path
238
+ * @returns {Promise<Object|null>} File data or null if not found
239
+ */
240
+ async getFile(path) {
241
+ const files = await this._getCurrentFiles();
242
+ const file = files.find(f => f.path === path);
243
+
244
+ if (!file) {
245
+ return null;
246
+ }
247
+
248
+ const content = await this._getBlob(file.blob_hash);
249
+
250
+ if (!content) {
251
+ return null;
252
+ }
253
+
254
+ return {
255
+ content,
256
+ sha: file.blob_hash,
257
+ path
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Save or update a file
263
+ * @param {string} path - File path
264
+ * @param {string} content - File content
265
+ * @param {string|null} sha - Expected current SHA (ignored, kept for API compatibility)
266
+ * @returns {Promise<Object>} Save result
267
+ */
268
+ async saveFile(path, content, sha = null) {
269
+ // Store blob
270
+ const blobHash = await this._storeBlob(content);
271
+
272
+ // Update search index
273
+ await this._updateSearchIndex(blobHash, content);
274
+
275
+ // Get current files
276
+ const currentFiles = await this._getCurrentFiles();
277
+
278
+ // Update or add the file
279
+ const existingIndex = currentFiles.findIndex(f => f.path === path);
280
+ if (existingIndex >= 0) {
281
+ currentFiles[existingIndex].blob_hash = blobHash;
282
+ } else {
283
+ currentFiles.push({ path, blob_hash: blobHash });
284
+ }
285
+
286
+ // Create commit
287
+ const message = sha ? `Update ${path}` : `Create ${path}`;
288
+ const commitId = await this._createCommit(currentFiles, message);
289
+
290
+ return {
291
+ commit: { sha: commitId },
292
+ content: { sha: blobHash, path }
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Delete a file
298
+ * @param {string} path - File path
299
+ * @param {string} sha - File SHA (ignored, kept for API compatibility)
300
+ * @returns {Promise<Object>} Delete result
301
+ */
302
+ async deleteFile(path, sha) {
303
+ // Get current files
304
+ const currentFiles = await this._getCurrentFiles();
305
+
306
+ // Remove the file
307
+ const newFiles = currentFiles.filter(f => f.path !== path);
308
+
309
+ if (newFiles.length === currentFiles.length) {
310
+ throw new Error('File not found');
311
+ }
312
+
313
+ // Create commit
314
+ const commitId = await this._createCommit(newFiles, `Delete ${path}`);
315
+
316
+ return {
317
+ commit: { sha: commitId }
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Create a folder (no-op in this system, folders are implicit)
323
+ * @param {string} path - Folder path
324
+ * @returns {Promise<Object>} Result
325
+ */
326
+ async createFolder(path) {
327
+ // In our system, folders are implicit and don't need explicit creation
328
+ // Return success for API compatibility
329
+ return { success: true };
330
+ }
331
+
332
+ /**
333
+ * Delete a folder and all its contents
334
+ * @param {string} path - Folder path
335
+ * @returns {Promise<Array>} Delete results
336
+ */
337
+ async deleteFolder(path) {
338
+ // Get current files
339
+ const currentFiles = await this._getCurrentFiles();
340
+
341
+ // Remove all files in the folder
342
+ const prefix = path.endsWith('/') ? path : `${path}/`;
343
+ const newFiles = currentFiles.filter(f => !f.path.startsWith(prefix));
344
+
345
+ if (newFiles.length === currentFiles.length) {
346
+ return []; // No files deleted
347
+ }
348
+
349
+ // Create commit
350
+ const commitId = await this._createCommit(newFiles, `Delete folder ${path}`);
351
+
352
+ return [{ commit: { sha: commitId } }];
353
+ }
354
+
355
+ /**
356
+ * Search notes for a query
357
+ * @param {string} query - Search query
358
+ * @returns {Promise<Array>} Search results
359
+ */
360
+ async searchNotes(query) {
361
+ // Use PostgreSQL full-text search
362
+ const searchResult = await this.db.query(
363
+ `SELECT si.blob_hash,
364
+ ts_rank(si.content_tsvector, query) as rank
365
+ FROM search_index si,
366
+ to_tsquery('english', $1) query
367
+ WHERE si.content_tsvector @@ query
368
+ ORDER BY rank DESC
369
+ LIMIT 50`,
370
+ [query.split(' ').join(' & ')]
371
+ );
372
+
373
+ const results = [];
374
+
375
+ // Get current files to map blob hashes to paths
376
+ const currentFiles = await this._getCurrentFiles();
377
+
378
+ for (const row of searchResult.rows) {
379
+ const file = currentFiles.find(f => f.blob_hash === row.blob_hash);
380
+ if (!file || !file.path.endsWith('.md')) continue;
381
+
382
+ const content = await this._getBlob(row.blob_hash);
383
+ if (!content) continue;
384
+
385
+ const lines = content.split('\n');
386
+ const matches = [];
387
+
388
+ lines.forEach((line, index) => {
389
+ if (line.toLowerCase().includes(query.toLowerCase())) {
390
+ matches.push({
391
+ line: index + 1,
392
+ content: line
393
+ });
394
+ }
395
+ });
396
+
397
+ if (matches.length > 0) {
398
+ results.push({
399
+ path: file.path,
400
+ matches
401
+ });
402
+ }
403
+ }
404
+
405
+ return results;
406
+ }
407
+
408
+ /**
409
+ * Get commit history for a specific file
410
+ * @param {string} path - File path
411
+ * @returns {Promise<Array>} Array of commits that affected this file
412
+ */
413
+ async getFileHistory(path) {
414
+ // Get all commits where this file exists
415
+ const result = await this.db.query(
416
+ `SELECT DISTINCT
417
+ c.id,
418
+ c.message,
419
+ c.author,
420
+ c.created_at,
421
+ t.blob_hash,
422
+ t.path
423
+ FROM commits c
424
+ INNER JOIN trees t ON t.commit_id = c.id
425
+ WHERE t.path = $1
426
+ ORDER BY c.created_at DESC`,
427
+ [path]
428
+ );
429
+
430
+ const commits = [];
431
+ let previousHash = null;
432
+
433
+ for (const row of result.rows) {
434
+ const changed = previousHash === null || previousHash !== row.blob_hash;
435
+
436
+ commits.push({
437
+ sha: row.id,
438
+ message: row.message,
439
+ author: row.author,
440
+ date: row.created_at,
441
+ blobHash: row.blob_hash,
442
+ changed
443
+ });
444
+
445
+ previousHash = row.blob_hash;
446
+ }
447
+
448
+ return commits;
449
+ }
450
+
451
+ /**
452
+ * Get the default branch (compatibility method)
453
+ * @returns {Promise<string>} Default branch name
454
+ */
455
+ async getDefaultBranch() {
456
+ return this.branch;
457
+ }
458
+ }
@@ -657,9 +657,6 @@ class NotesEditor {
657
657
  }
658
658
  }
659
659
 
660
- // Export for different module systems
661
- if (typeof module !== 'undefined' && module.exports) {
662
- module.exports = { NotesEditor };
663
- } else {
664
- window.NotesEditor = NotesEditor;
665
- }
660
+
661
+ export { NotesEditor };
662
+ export default NotesEditor;
package/lib/index.js CHANGED
@@ -8,21 +8,10 @@
8
8
  */
9
9
 
10
10
  // Backend exports
11
- const { createNotesRouter } = require('./backend/index');
12
- const { GitHubClient } = require('./backend/github');
13
- const { StorageClient } = require('./backend/storage');
14
- const { MarkdownRenderer } = require('./backend/markdown');
11
+ export { createNotesRouter } from './backend/index.js';
12
+ export { GitHubClient } from './backend/github.js';
13
+ export { StorageClient } from './backend/storage.js';
14
+ export { MarkdownRenderer } from './backend/markdown.js';
15
15
 
16
16
  // Frontend exports
17
- const { NotesEditor } = require('./frontend/index');
18
-
19
- module.exports = {
20
- // Backend
21
- createNotesRouter,
22
- GitHubClient,
23
- StorageClient,
24
- MarkdownRenderer,
25
-
26
- // Frontend
27
- NotesEditor
28
- };
17
+ export { NotesEditor } from './frontend/index.js';
package/package.json CHANGED
@@ -1,34 +1,18 @@
1
1
  {
2
2
  "name": "markdown-notes-engine",
3
- "version": "1.0.2",
4
- "description": "A complete markdown note-taking engine with GitHub integration and media hosting (R2/S3)",
3
+ "version": "2.0.1",
4
+ "description": "A complete markdown note-taking engine with Git-like version control (PostgreSQL) or GitHub integration and media hosting (R2/S3)",
5
+ "type": "module",
5
6
  "main": "lib/index.js",
6
- "module": "lib/index.mjs",
7
7
  "exports": {
8
- ".": {
9
- "import": "./lib/index.mjs",
10
- "require": "./lib/index.js"
11
- },
12
- "./backend": {
13
- "import": "./lib/backend/index.mjs",
14
- "require": "./lib/backend/index.js"
15
- },
16
- "./backend/github": {
17
- "import": "./lib/backend/github.mjs",
18
- "require": "./lib/backend/github.js"
19
- },
20
- "./backend/storage": {
21
- "import": "./lib/backend/storage.mjs",
22
- "require": "./lib/backend/storage.js"
23
- },
24
- "./backend/markdown": {
25
- "import": "./lib/backend/markdown.mjs",
26
- "require": "./lib/backend/markdown.js"
27
- },
28
- "./frontend": {
29
- "import": "./lib/frontend/index.mjs",
30
- "require": "./lib/frontend/index.js"
31
- }
8
+ ".": "./lib/index.js",
9
+ "./backend": "./lib/backend/index.js",
10
+ "./backend/github": "./lib/backend/github.js",
11
+ "./backend/storage": "./lib/backend/storage.js",
12
+ "./backend/markdown": "./lib/backend/markdown.js",
13
+ "./backend/version-control": "./lib/backend/version-control.js",
14
+ "./backend/db": "./lib/backend/db/connection.js",
15
+ "./frontend": "./lib/frontend/index.js"
32
16
  },
33
17
  "scripts": {
34
18
  "start": "node server.js",
@@ -38,6 +22,9 @@
38
22
  "keywords": [
39
23
  "markdown",
40
24
  "notes",
25
+ "version-control",
26
+ "git",
27
+ "postgresql",
41
28
  "github",
42
29
  "editor",
43
30
  "r2",
@@ -53,18 +40,21 @@
53
40
  },
54
41
  "files": [
55
42
  "lib/**/*.js",
56
- "lib/**/*.mjs",
43
+ "lib/**/*.sql",
57
44
  "README.md",
58
45
  "LICENSE"
59
46
  ],
60
47
  "dependencies": {
61
48
  "@aws-sdk/client-s3": "^3.928.0",
62
- "@octokit/rest": "^22.0.1",
63
49
  "express": "^5.1.0",
64
50
  "express-fileupload": "^1.5.2",
65
51
  "highlight.js": "^11.11.1",
66
52
  "marked": "^17.0.0",
67
- "marked-highlight": "^2.2.3"
53
+ "marked-highlight": "^2.2.3",
54
+ "postgres": "^3.4.5"
55
+ },
56
+ "optionalDependencies": {
57
+ "@octokit/rest": "^22.0.1"
68
58
  },
69
59
  "devDependencies": {
70
60
  "dotenv": "^17.2.3",