markdown-notes-engine 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.
@@ -0,0 +1,318 @@
1
+ /**
2
+ * GitHub Client Wrapper
3
+ * Handles all GitHub API operations for note management
4
+ */
5
+
6
+ const { Octokit } = require('@octokit/rest');
7
+
8
+ class GitHubClient {
9
+ constructor({ token, owner, repo, branch = 'main' }) {
10
+ this.octokit = new Octokit({ auth: token });
11
+ this.owner = owner;
12
+ this.repo = repo;
13
+ this.branch = branch;
14
+ }
15
+
16
+ /**
17
+ * Get the default branch for the repository
18
+ * @returns {Promise<string>} Default branch name
19
+ */
20
+ async getDefaultBranch() {
21
+ try {
22
+ const response = await this.octokit.repos.get({
23
+ owner: this.owner,
24
+ repo: this.repo
25
+ });
26
+ return response.data.default_branch;
27
+ } catch (error) {
28
+ console.error('Failed to get default branch:', error.message);
29
+ return 'main'; // fallback
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Get repository file tree structure
35
+ * @returns {Promise<Array>} Array of files and folders
36
+ */
37
+ async getFileStructure() {
38
+ try {
39
+ // Get the entire tree recursively using git API
40
+ const { data } = await this.octokit.git.getTree({
41
+ owner: this.owner,
42
+ repo: this.repo,
43
+ tree_sha: this.branch,
44
+ recursive: '1'
45
+ });
46
+
47
+ // Filter to only blob files (not trees)
48
+ const files = data.tree.filter(item => item.type === 'blob');
49
+
50
+ return this._buildStructureFromFiles(files);
51
+ } catch (error) {
52
+ if (error.status === 404) {
53
+ // Branch doesn't exist, try to get default branch
54
+ try {
55
+ const defaultBranch = await this.getDefaultBranch();
56
+ this.branch = defaultBranch; // Update for future calls
57
+
58
+ const { data } = await this.octokit.git.getTree({
59
+ owner: this.owner,
60
+ repo: this.repo,
61
+ tree_sha: defaultBranch,
62
+ recursive: '1'
63
+ });
64
+
65
+ const files = data.tree.filter(item => item.type === 'blob');
66
+ return this._buildStructureFromFiles(files);
67
+ } catch (retryError) {
68
+ // Repository might be empty
69
+ console.error('Repository appears to be empty or inaccessible:', retryError.message);
70
+ return []; // Return empty array for empty repos
71
+ }
72
+ }
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Get file content
79
+ * @param {string} path - File path
80
+ * @returns {Promise<Object>} File content and metadata
81
+ */
82
+ async getFile(path) {
83
+ try {
84
+ const response = await this.octokit.repos.getContent({
85
+ owner: this.owner,
86
+ repo: this.repo,
87
+ path: path,
88
+ ref: this.branch
89
+ });
90
+
91
+ // Check if this is a file (not a directory)
92
+ if (!response.data.content) {
93
+ console.error('No content in response - might be a directory');
94
+ return null;
95
+ }
96
+
97
+ return {
98
+ content: Buffer.from(response.data.content, 'base64').toString('utf-8'),
99
+ sha: response.data.sha,
100
+ path: response.data.path
101
+ };
102
+ } catch (error) {
103
+ if (error.status === 404) {
104
+ return null;
105
+ }
106
+ throw error;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Create or update a file
112
+ * @param {string} path - File path
113
+ * @param {string} content - File content
114
+ * @param {string} [sha] - File SHA (required for updates)
115
+ * @returns {Promise<Object>} Response from GitHub
116
+ */
117
+ async saveFile(path, content, sha = null) {
118
+ const params = {
119
+ owner: this.owner,
120
+ repo: this.repo,
121
+ path: path,
122
+ message: sha ? `Update ${path}` : `Create ${path}`,
123
+ content: Buffer.from(content).toString('base64'),
124
+ branch: this.branch
125
+ };
126
+
127
+ if (sha) {
128
+ params.sha = sha;
129
+ }
130
+
131
+ const response = await this.octokit.repos.createOrUpdateFileContents(params);
132
+ return response.data;
133
+ }
134
+
135
+ /**
136
+ * Delete a file
137
+ * @param {string} path - File path
138
+ * @param {string} sha - File SHA
139
+ * @returns {Promise<Object>} Response from GitHub
140
+ */
141
+ async deleteFile(path, sha) {
142
+ const response = await this.octokit.repos.deleteFile({
143
+ owner: this.owner,
144
+ repo: this.repo,
145
+ path: path,
146
+ message: `Delete ${path}`,
147
+ sha: sha,
148
+ branch: this.branch
149
+ });
150
+
151
+ return response.data;
152
+ }
153
+
154
+ /**
155
+ * Create a folder (by creating a .gitkeep file)
156
+ * @param {string} path - Folder path
157
+ * @returns {Promise<Object>} Response from GitHub
158
+ */
159
+ async createFolder(path) {
160
+ const gitkeepPath = `${path}/.gitkeep`;
161
+ return this.saveFile(gitkeepPath, '', null);
162
+ }
163
+
164
+ /**
165
+ * Delete a folder (recursively delete all files)
166
+ * @param {string} path - Folder path
167
+ * @returns {Promise<Array>} Array of delete responses
168
+ */
169
+ async deleteFolder(path) {
170
+ const files = await this._getFilesInFolder(path);
171
+ const deletePromises = files.map(file =>
172
+ this.deleteFile(file.path, file.sha)
173
+ );
174
+
175
+ return Promise.all(deletePromises);
176
+ }
177
+
178
+ /**
179
+ * Search notes for a query
180
+ * @param {string} query - Search query
181
+ * @returns {Promise<Array>} Search results
182
+ */
183
+ async searchNotes(query) {
184
+ const searchQuery = `${query} repo:${this.owner}/${this.repo}`;
185
+ const response = await this.octokit.search.code({
186
+ q: searchQuery
187
+ });
188
+
189
+ const results = [];
190
+
191
+ for (const item of response.data.items) {
192
+ if (!item.path.endsWith('.md')) continue;
193
+
194
+ const file = await this.getFile(item.path);
195
+ if (!file) continue;
196
+
197
+ const lines = file.content.split('\n');
198
+ const matches = [];
199
+
200
+ lines.forEach((line, index) => {
201
+ if (line.toLowerCase().includes(query.toLowerCase())) {
202
+ matches.push({
203
+ line: index + 1,
204
+ content: line
205
+ });
206
+ }
207
+ });
208
+
209
+ if (matches.length > 0) {
210
+ results.push({
211
+ path: item.path,
212
+ matches: matches
213
+ });
214
+ }
215
+ }
216
+
217
+ return results;
218
+ }
219
+
220
+ /**
221
+ * Get all files in a folder
222
+ * @private
223
+ */
224
+ async _getFilesInFolder(path) {
225
+ const response = await this.octokit.repos.getContent({
226
+ owner: this.owner,
227
+ repo: this.repo,
228
+ path: path,
229
+ ref: this.branch
230
+ });
231
+
232
+ let files = [];
233
+
234
+ for (const item of response.data) {
235
+ if (item.type === 'file') {
236
+ files.push(item);
237
+ } else if (item.type === 'dir') {
238
+ const subFiles = await this._getFilesInFolder(item.path);
239
+ files = files.concat(subFiles);
240
+ }
241
+ }
242
+
243
+ return files;
244
+ }
245
+
246
+ /**
247
+ * Build hierarchical structure from flat file list
248
+ * @private
249
+ */
250
+ _buildStructureFromFiles(files) {
251
+ const root = [];
252
+ const folderMap = new Map();
253
+
254
+ // Sort files to ensure consistent ordering
255
+ files.sort((a, b) => a.path.localeCompare(b.path));
256
+
257
+ for (const file of files) {
258
+ const parts = file.path.split('/');
259
+ let currentLevel = root;
260
+ let currentPath = '';
261
+
262
+ // Process each part of the path
263
+ for (let i = 0; i < parts.length; i++) {
264
+ const part = parts[i];
265
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
266
+
267
+ if (i === parts.length - 1) {
268
+ // It's a file - skip .gitkeep files
269
+ if (part === '.gitkeep') {
270
+ continue;
271
+ }
272
+
273
+ currentLevel.push({
274
+ name: part,
275
+ type: 'file',
276
+ path: file.path,
277
+ sha: file.sha
278
+ });
279
+ } else {
280
+ // It's a folder
281
+ let folder = folderMap.get(currentPath);
282
+
283
+ if (!folder) {
284
+ folder = {
285
+ name: part,
286
+ type: 'folder',
287
+ path: currentPath,
288
+ children: []
289
+ };
290
+ folderMap.set(currentPath, folder);
291
+ currentLevel.push(folder);
292
+ }
293
+
294
+ currentLevel = folder.children;
295
+ }
296
+ }
297
+ }
298
+
299
+ // Sort function: folders first, then alphabetically
300
+ const sortStructure = (items) => {
301
+ items.sort((a, b) => {
302
+ if (a.type === b.type) return a.name.localeCompare(b.name);
303
+ return a.type === 'folder' ? -1 : 1;
304
+ });
305
+
306
+ items.forEach((item) => {
307
+ if (item.type === 'folder' && item.children) {
308
+ sortStructure(item.children);
309
+ }
310
+ });
311
+ };
312
+
313
+ sortStructure(root);
314
+ return root;
315
+ }
316
+ }
317
+
318
+ module.exports = { GitHubClient };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Markdown Notes Engine - Backend Router
3
+ *
4
+ * Creates an Express router with all note-taking API endpoints
5
+ */
6
+
7
+ const express = require('express');
8
+ const { GitHubClient } = require('./github');
9
+ const { StorageClient } = require('./storage');
10
+ const { MarkdownRenderer } = require('./markdown');
11
+ const notesRoutes = require('./routes/notes');
12
+ const uploadRoutes = require('./routes/upload');
13
+ const searchRoutes = require('./routes/search');
14
+
15
+ /**
16
+ * Creates a configured notes router
17
+ * @param {Object} config - Configuration object
18
+ * @param {Object} config.github - GitHub configuration
19
+ * @param {string} config.github.token - GitHub personal access token
20
+ * @param {string} config.github.owner - Repository owner
21
+ * @param {string} config.github.repo - Repository name
22
+ * @param {string} [config.github.branch='main'] - Repository branch (defaults to 'main')
23
+ * @param {Object} config.storage - Storage configuration (R2 or S3)
24
+ * @param {string} config.storage.type - 'r2' or 's3'
25
+ * @param {string} config.storage.accountId - Account ID (R2) or region (S3)
26
+ * @param {string} config.storage.accessKeyId - Access key ID
27
+ * @param {string} config.storage.secretAccessKey - Secret access key
28
+ * @param {string} config.storage.bucketName - Bucket name
29
+ * @param {string} config.storage.publicUrl - Public URL for accessing files
30
+ * @param {Object} [config.options] - Optional configuration
31
+ * @param {boolean} [config.options.autoUpdateReadme=true] - Auto-update README on note save
32
+ * @returns {express.Router} Configured Express router
33
+ */
34
+ function createNotesRouter(config) {
35
+ if (!config.github || !config.github.token || !config.github.owner || !config.github.repo) {
36
+ throw new Error('GitHub configuration is required: token, owner, and repo');
37
+ }
38
+
39
+ if (!config.storage) {
40
+ throw new Error('Storage configuration is required');
41
+ }
42
+
43
+ const router = express.Router();
44
+
45
+ // Initialize clients
46
+ const githubClient = new GitHubClient({
47
+ token: config.github.token,
48
+ owner: config.github.owner,
49
+ repo: config.github.repo,
50
+ branch: config.github.branch || 'main'
51
+ });
52
+
53
+ const storageClient = new StorageClient(config.storage);
54
+ const markdownRenderer = new MarkdownRenderer();
55
+ const options = config.options || {};
56
+
57
+ // Middleware to attach clients to request
58
+ router.use((req, res, next) => {
59
+ req.notesEngine = {
60
+ githubClient,
61
+ storageClient,
62
+ markdownRenderer,
63
+ options
64
+ };
65
+ next();
66
+ });
67
+
68
+ // Mount route handlers
69
+ router.use(notesRoutes);
70
+ router.use(uploadRoutes);
71
+ router.use(searchRoutes);
72
+
73
+ return router;
74
+ }
75
+
76
+ module.exports = { createNotesRouter };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Markdown Renderer
3
+ * Renders markdown to HTML with syntax highlighting
4
+ */
5
+
6
+ const { marked } = require('marked');
7
+ const { markedHighlight } = require('marked-highlight');
8
+ const hljs = require('highlight.js');
9
+
10
+ class MarkdownRenderer {
11
+ constructor() {
12
+ // Configure marked with syntax highlighting
13
+ marked.use(markedHighlight({
14
+ langPrefix: 'hljs language-',
15
+ highlight(code, lang) {
16
+ const language = hljs.getLanguage(lang) ? lang : 'plaintext';
17
+ return hljs.highlight(code, { language }).value;
18
+ }
19
+ }));
20
+
21
+ // Custom renderer for links and videos
22
+ const renderer = new marked.Renderer();
23
+
24
+ // Custom link renderer - handles internal markdown links
25
+ renderer.link = (href, title, text) => {
26
+ if (href.endsWith('.md')) {
27
+ return `<a href="#" class="internal-link" data-path="${href}">${text}</a>`;
28
+ }
29
+ return `<a href="${href}" ${title ? `title="${title}"` : ''}>${text}</a>`;
30
+ };
31
+
32
+ // Custom image renderer - detects videos and renders video tag
33
+ renderer.image = (href, title, text) => {
34
+ // Check if it's a video
35
+ const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
36
+ const isVideo = videoExtensions.some(ext => href.toLowerCase().endsWith(ext)) ||
37
+ href.includes('/videos/');
38
+
39
+ if (isVideo) {
40
+ return `<video controls style="max-width: 100%;" ${title ? `title="${title}"` : ''}>
41
+ <source src="${href}" type="video/mp4">
42
+ Your browser does not support the video tag.
43
+ </video>`;
44
+ }
45
+
46
+ return `<img src="${href}" alt="${text}" ${title ? `title="${title}"` : ''} style="max-width: 100%;">`;
47
+ };
48
+
49
+ marked.use({ renderer });
50
+ }
51
+
52
+ /**
53
+ * Render markdown to HTML
54
+ * @param {string} markdown - Markdown content
55
+ * @returns {string} HTML content
56
+ */
57
+ render(markdown) {
58
+ return marked(markdown);
59
+ }
60
+ }
61
+
62
+ module.exports = { MarkdownRenderer };
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Notes Routes
3
+ * Handles note CRUD operations
4
+ */
5
+
6
+ const express = require('express');
7
+ const router = express.Router();
8
+
9
+ // Get file structure
10
+ router.get('/structure', async (req, res) => {
11
+ try {
12
+ const { githubClient } = req.notesEngine;
13
+ const structure = await githubClient.getFileStructure();
14
+ res.json({ structure });
15
+ } catch (error) {
16
+ console.error('Error fetching structure:', error);
17
+ res.status(500).json({ error: 'Failed to fetch file structure' });
18
+ }
19
+ });
20
+
21
+ // Get note content
22
+ router.get('/note', async (req, res) => {
23
+ try {
24
+ const { path } = req.query;
25
+ if (!path) {
26
+ return res.status(400).json({ error: 'Path is required' });
27
+ }
28
+
29
+ const { githubClient } = req.notesEngine;
30
+ const file = await githubClient.getFile(path);
31
+
32
+ if (!file) {
33
+ return res.status(404).json({ error: 'Note not found' });
34
+ }
35
+
36
+ res.json(file);
37
+ } catch (error) {
38
+ console.error('Error fetching note:', error);
39
+ res.status(500).json({ error: 'Failed to fetch note' });
40
+ }
41
+ });
42
+
43
+ // Save note
44
+ router.post('/note', async (req, res) => {
45
+ try {
46
+ let { path, content } = req.body;
47
+
48
+ if (!path || content === undefined) {
49
+ return res.status(400).json({ error: 'Path and content are required' });
50
+ }
51
+
52
+ // Ensure .md extension
53
+ if (!path.endsWith('.md')) {
54
+ path += '.md';
55
+ }
56
+
57
+ const { githubClient, options } = req.notesEngine;
58
+
59
+ // Get existing file SHA if it exists
60
+ const existingFile = await githubClient.getFile(path);
61
+ const sha = existingFile ? existingFile.sha : null;
62
+
63
+ // Save the file
64
+ await githubClient.saveFile(path, content, sha);
65
+
66
+ // Auto-update README if enabled
67
+ if (options.autoUpdateReadme !== false && path !== 'README.md') {
68
+ try {
69
+ await updateReadme(githubClient);
70
+ } catch (error) {
71
+ console.error('Error updating README:', error);
72
+ // Don't fail the request if README update fails
73
+ }
74
+ }
75
+
76
+ res.json({ success: true, path });
77
+ } catch (error) {
78
+ console.error('Error saving note:', error);
79
+ res.status(500).json({ error: 'Failed to save note' });
80
+ }
81
+ });
82
+
83
+ // Delete note
84
+ router.delete('/note', async (req, res) => {
85
+ try {
86
+ const { path } = req.body;
87
+
88
+ if (!path) {
89
+ return res.status(400).json({ error: 'Path is required' });
90
+ }
91
+
92
+ const { githubClient } = req.notesEngine;
93
+
94
+ // Get file SHA
95
+ const file = await githubClient.getFile(path);
96
+ if (!file) {
97
+ return res.status(404).json({ error: 'Note not found' });
98
+ }
99
+
100
+ await githubClient.deleteFile(path, file.sha);
101
+
102
+ res.json({ success: true });
103
+ } catch (error) {
104
+ console.error('Error deleting note:', error);
105
+ res.status(500).json({ error: 'Failed to delete note' });
106
+ }
107
+ });
108
+
109
+ // Create folder
110
+ router.post('/folder', async (req, res) => {
111
+ try {
112
+ const { path } = req.body;
113
+
114
+ if (!path) {
115
+ return res.status(400).json({ error: 'Path is required' });
116
+ }
117
+
118
+ const { githubClient } = req.notesEngine;
119
+ await githubClient.createFolder(path);
120
+
121
+ res.json({ success: true });
122
+ } catch (error) {
123
+ console.error('Error creating folder:', error);
124
+ res.status(500).json({ error: 'Failed to create folder' });
125
+ }
126
+ });
127
+
128
+ // Delete folder
129
+ router.delete('/folder', async (req, res) => {
130
+ try {
131
+ const { path } = req.body;
132
+
133
+ if (!path) {
134
+ return res.status(400).json({ error: 'Path is required' });
135
+ }
136
+
137
+ const { githubClient } = req.notesEngine;
138
+ await githubClient.deleteFolder(path);
139
+
140
+ res.json({ success: true });
141
+ } catch (error) {
142
+ console.error('Error deleting folder:', error);
143
+ res.status(500).json({ error: 'Failed to delete folder' });
144
+ }
145
+ });
146
+
147
+ // Render markdown to HTML
148
+ router.post('/render', async (req, res) => {
149
+ try {
150
+ const { markdown } = req.body;
151
+
152
+ if (markdown === undefined) {
153
+ return res.status(400).json({ error: 'Markdown content is required' });
154
+ }
155
+
156
+ const { markdownRenderer } = req.notesEngine;
157
+ const html = markdownRenderer.render(markdown);
158
+
159
+ res.json({ html });
160
+ } catch (error) {
161
+ console.error('Error rendering markdown:', error);
162
+ res.status(500).json({ error: 'Failed to render markdown' });
163
+ }
164
+ });
165
+
166
+ /**
167
+ * Auto-update README with list of notes
168
+ * @private
169
+ */
170
+ async function updateReadme(githubClient) {
171
+ const structure = await githubClient.getFileStructure();
172
+
173
+ let readmeContent = '# Notes\n\n';
174
+ readmeContent += 'This is an auto-generated index of all notes.\n\n';
175
+
176
+ const mdFiles = structure
177
+ .filter(item => item.type === 'file' && item.name.endsWith('.md') && item.name !== 'README.md')
178
+ .sort((a, b) => a.name.localeCompare(b.name));
179
+
180
+ if (mdFiles.length === 0) {
181
+ readmeContent += '*No notes yet.*\n';
182
+ } else {
183
+ mdFiles.forEach(file => {
184
+ const displayName = file.name.replace('.md', '');
185
+ readmeContent += `- [${displayName}](${file.path})\n`;
186
+ });
187
+ }
188
+
189
+ readmeContent += `\n---\n*Last updated: ${new Date().toISOString()}*\n`;
190
+
191
+ const existingReadme = await githubClient.getFile('README.md');
192
+ const sha = existingReadme ? existingReadme.sha : null;
193
+
194
+ await githubClient.saveFile('README.md', readmeContent, sha);
195
+ }
196
+
197
+ module.exports = router;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Search Routes
3
+ * Handles note searching
4
+ */
5
+
6
+ const express = require('express');
7
+ const router = express.Router();
8
+
9
+ // Search notes
10
+ router.get('/search', async (req, res) => {
11
+ try {
12
+ const { q: query } = req.query;
13
+
14
+ if (!query) {
15
+ return res.status(400).json({ error: 'Query parameter "q" is required' });
16
+ }
17
+
18
+ const { githubClient } = req.notesEngine;
19
+ const results = await githubClient.searchNotes(query);
20
+
21
+ res.json({ results });
22
+ } catch (error) {
23
+ console.error('Error searching notes:', error);
24
+ res.status(500).json({ error: 'Failed to search notes' });
25
+ }
26
+ });
27
+
28
+ module.exports = router;