collabmd 0.1.18 → 0.1.20

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 (64) hide show
  1. package/README.md +107 -25
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/public/assets/css/style.css +1 -1
  5. package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
  6. package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
  7. package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
  8. package/public/assets/js/chunks/editor-session-TAV2VXTA.js +22 -0
  9. package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
  10. package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
  11. package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
  12. package/public/assets/js/excalidraw-editor.js +1 -1
  13. package/public/assets/js/main.js +100 -73
  14. package/public/assets/js/preview-render-worker.js +19 -19
  15. package/public/index.html +14 -4
  16. package/src/client/application/app-shell/git-feature.js +29 -1
  17. package/src/client/application/app-shell/presence-feature.js +2 -1
  18. package/src/client/application/app-shell/ui-feature.js +113 -1
  19. package/src/client/application/app-shell/workspace-feature.js +9 -0
  20. package/src/client/application/app-shell-elements.js +1 -0
  21. package/src/client/application/preview-render-compiler.js +55 -5
  22. package/src/client/application/preview-render-executor.js +8 -0
  23. package/src/client/application/preview-render-worker.js +13 -2
  24. package/src/client/application/preview-renderer.js +5 -0
  25. package/src/client/application/workspace-chrome-controller.js +12 -1
  26. package/src/client/application/workspace-coordinator.js +16 -7
  27. package/src/client/application/workspace-preview-controller.js +45 -5
  28. package/src/client/application/workspace-route-controller.js +4 -0
  29. package/src/client/bootstrap/collabmd-app-shell.js +19 -3
  30. package/src/client/domain/room.js +37 -0
  31. package/src/client/domain/vault-utils.js +46 -0
  32. package/src/client/infrastructure/comment-thread-store.js +98 -0
  33. package/src/client/infrastructure/editor-paste-utils.js +45 -0
  34. package/src/client/infrastructure/editor-session.js +10 -0
  35. package/src/client/infrastructure/editor-view-adapter.js +98 -7
  36. package/src/client/infrastructure/vault-api-client.js +17 -0
  37. package/src/client/presentation/backlinks-panel.js +157 -101
  38. package/src/client/presentation/comment-markdown-renderer.js +68 -0
  39. package/src/client/presentation/comment-ui-controller.js +352 -20
  40. package/src/client/presentation/file-explorer-controller.js +5 -0
  41. package/src/client/presentation/file-explorer-view.js +10 -2
  42. package/src/client/presentation/file-tree-state.js +7 -1
  43. package/src/client/presentation/git-panel-controller.js +73 -0
  44. package/src/client/presentation/image-lightbox-controller.js +394 -0
  45. package/src/client/presentation/outline-controller.js +25 -0
  46. package/src/client/styles/style.css +677 -21
  47. package/src/domain/comment-threads.js +95 -13
  48. package/src/domain/file-kind.js +16 -1
  49. package/src/server/infrastructure/git/errors.js +4 -1
  50. package/src/server/infrastructure/git/git-service.js +215 -1
  51. package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
  52. package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
  53. package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
  54. package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
  55. package/src/server/infrastructure/http/request-body.js +11 -2
  56. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  57. package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
  58. package/src/server/infrastructure/persistence/vault-file-store.js +203 -2
  59. package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
  60. package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
  61. package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
  62. package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
  63. package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
  64. package/public/assets/js/chunks/quick-switcher-controller-JYDIJVAJ.js +0 -5
@@ -2,7 +2,7 @@ import { createRequestError } from './http-errors.js';
2
2
 
3
3
  export const REQUEST_BODY_LIMIT_BYTES = 8_388_608;
4
4
 
5
- export async function readRequestBody(req, maxBytes = REQUEST_BODY_LIMIT_BYTES) {
5
+ async function readRequestBuffer(req, maxBytes = REQUEST_BODY_LIMIT_BYTES) {
6
6
  return new Promise((resolve, reject) => {
7
7
  const chunks = [];
8
8
  let size = 0;
@@ -32,7 +32,7 @@ export async function readRequestBody(req, maxBytes = REQUEST_BODY_LIMIT_BYTES)
32
32
  };
33
33
 
34
34
  const onEnd = () => {
35
- finish(resolve, Buffer.concat(chunks).toString('utf-8'));
35
+ finish(resolve, Buffer.concat(chunks));
36
36
  };
37
37
 
38
38
  const onError = (error) => {
@@ -45,6 +45,15 @@ export async function readRequestBody(req, maxBytes = REQUEST_BODY_LIMIT_BYTES)
45
45
  });
46
46
  }
47
47
 
48
+ export async function readRequestBody(req, maxBytes = REQUEST_BODY_LIMIT_BYTES) {
49
+ const bodyBuffer = await readRequestBuffer(req, maxBytes);
50
+ return bodyBuffer.toString('utf-8');
51
+ }
52
+
53
+ export async function readBinaryRequestBody(req, maxBytes = REQUEST_BODY_LIMIT_BYTES) {
54
+ return readRequestBuffer(req, maxBytes);
55
+ }
56
+
48
57
  export async function parseJsonBody(req) {
49
58
  const rawBody = await readRequestBody(req);
50
59
 
@@ -3,7 +3,7 @@ import { isAbsolute, normalize, relative, resolve } from 'path';
3
3
  import { isVaultFilePath } from '../../../domain/file-kind.js';
4
4
 
5
5
  export const IGNORED_DIRECTORIES = new Set(['.git', '.obsidian', '.trash', 'node_modules', '.DS_Store']);
6
- export const VAULT_FILE_PATH_REQUIREMENT = '.md, .excalidraw, .mmd, .mermaid, .puml, or .plantuml';
6
+ export const VAULT_FILE_PATH_REQUIREMENT = '.md, .excalidraw, .mmd, .mermaid, .puml, .plantuml, .png, .jpg, .jpeg, .webp, .gif, or .svg';
7
7
  export const INVALID_VAULT_FILE_PATH_ERROR = `Invalid file path — must end in ${VAULT_FILE_PATH_REQUIREMENT}`;
8
8
  export const INVALID_DIRECTORY_PATH_ERROR = 'Invalid directory path';
9
9
 
@@ -0,0 +1,283 @@
1
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ import { sanitizeVaultPath } from './path-utils.js';
5
+
6
+ const PULL_BACKUP_STORAGE_ROOT = '.collabmd/pull-backups';
7
+ const BACKUP_METADATA_PREFIX = '<!-- collabmd-pull-backup ';
8
+ const BACKUP_METADATA_SUFFIX = ' -->';
9
+
10
+ function normalizeRelativePath(pathValue = '') {
11
+ return String(pathValue).replace(/\\/g, '/');
12
+ }
13
+
14
+ function createBackupId(headRef = null, createdAt = new Date().toISOString()) {
15
+ const timestamp = normalizeRelativePath(createdAt)
16
+ .replace(/[^0-9T]/g, '')
17
+ .replace('T', '-')
18
+ .replace(/Z$/u, '');
19
+ const shortHead = String(headRef ?? 'workspace').trim().slice(0, 7) || 'workspace';
20
+ return `${timestamp}-${shortHead}`;
21
+ }
22
+
23
+ function formatBackupMetadata(metadata = {}) {
24
+ return `${BACKUP_METADATA_PREFIX}${JSON.stringify(metadata)}${BACKUP_METADATA_SUFFIX}`;
25
+ }
26
+
27
+ function parseBackupMetadata(content = '') {
28
+ const firstLine = String(content).split(/\r?\n/u)[0] ?? '';
29
+ if (!firstLine.startsWith(BACKUP_METADATA_PREFIX) || !firstLine.endsWith(BACKUP_METADATA_SUFFIX)) {
30
+ return null;
31
+ }
32
+
33
+ try {
34
+ return JSON.parse(firstLine.slice(BACKUP_METADATA_PREFIX.length, -BACKUP_METADATA_SUFFIX.length));
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ function formatSummary({
41
+ backupEntries = [],
42
+ backupId,
43
+ branch = null,
44
+ createdAt,
45
+ fileCount = 0,
46
+ headRef = null,
47
+ targetRef = null,
48
+ } = {}) {
49
+ const metadata = formatBackupMetadata({
50
+ backupId,
51
+ branch,
52
+ createdAt,
53
+ fileCount,
54
+ headRef,
55
+ targetRef,
56
+ });
57
+ const entryLines = backupEntries.length === 0
58
+ ? ['No overlapping files could be copied from the worktree.']
59
+ : backupEntries.flatMap((entry) => {
60
+ const lines = [`- Original path: \`${entry.path}\``];
61
+ if (entry.backupPath) {
62
+ lines.push(` Saved copy: \`${entry.backupPath}\``);
63
+ } else {
64
+ lines.push(' Saved copy: none (the file no longer existed in the worktree at backup time)');
65
+ }
66
+ if (entry.stagedPatchPath) {
67
+ lines.push(` Staged patch: \`${entry.stagedPatchPath}\``);
68
+ }
69
+ if (entry.worktreePatchPath) {
70
+ lines.push(` Worktree patch: \`${entry.worktreePatchPath}\``);
71
+ }
72
+ return lines;
73
+ });
74
+
75
+ return [
76
+ metadata,
77
+ '# Pull Backup',
78
+ '',
79
+ `Created: \`${createdAt}\``,
80
+ `Branch: \`${branch || 'HEAD'}\``,
81
+ `Base ref: \`${headRef || 'none'}\``,
82
+ `Pulled to: \`${targetRef || 'none'}\``,
83
+ `Files backed up: \`${fileCount}\``,
84
+ '',
85
+ 'This backup was created because local dirty changes overlapped with incoming remote updates.',
86
+ 'The working tree was updated to the remote version so the latest upstream content is visible immediately.',
87
+ '',
88
+ '## Saved Files',
89
+ '',
90
+ ...entryLines,
91
+ '',
92
+ '## Recovery',
93
+ '',
94
+ 'Compare the current file content against the saved copies above, then manually restore any changes you want to keep.',
95
+ 'Use `git apply --cached <staged patch>` to inspect or restore staged/index-only changes on a safe branch.',
96
+ 'Use `git apply <worktree patch>` to inspect or restore unstaged worktree changes on a safe branch.',
97
+ '',
98
+ ].join('\n');
99
+ }
100
+
101
+ async function ensureLocalGitExclude(vaultDir) {
102
+ const excludePath = resolve(vaultDir, '.git/info/exclude');
103
+ let existingContent = '';
104
+
105
+ try {
106
+ existingContent = await readFile(excludePath, 'utf8');
107
+ } catch (error) {
108
+ if (error?.code !== 'ENOENT') {
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ const lines = existingContent
114
+ .split(/\r?\n/u)
115
+ .map((line) => line.trim())
116
+ .filter(Boolean);
117
+ if (lines.includes('.collabmd') || lines.includes('.collabmd/')) {
118
+ return;
119
+ }
120
+
121
+ const prefix = existingContent.length > 0 && !existingContent.endsWith('\n')
122
+ ? '\n'
123
+ : '';
124
+ await writeFile(excludePath, `${existingContent}${prefix}.collabmd/\n`, 'utf8');
125
+ }
126
+
127
+ export class PullBackupStore {
128
+ constructor({ vaultDir }) {
129
+ this.vaultDir = resolve(vaultDir);
130
+ }
131
+
132
+ getStorageRoot() {
133
+ return resolve(this.vaultDir, PULL_BACKUP_STORAGE_ROOT);
134
+ }
135
+
136
+ getBackupPath(backupId) {
137
+ return resolve(this.getStorageRoot(), backupId);
138
+ }
139
+
140
+ getSummaryPath(backupId) {
141
+ return `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/summary.md`;
142
+ }
143
+
144
+ async createBackup({
145
+ branch = null,
146
+ createdAt = new Date().toISOString(),
147
+ entries = [],
148
+ headRef = null,
149
+ targetRef = null,
150
+ } = {}) {
151
+ await ensureLocalGitExclude(this.vaultDir);
152
+
153
+ const backupId = createBackupId(headRef, createdAt);
154
+ const backupDir = this.getBackupPath(backupId);
155
+ const filesRoot = join(backupDir, 'files');
156
+ const patchesRoot = join(backupDir, 'patches');
157
+ const backupEntries = [];
158
+
159
+ await mkdir(filesRoot, { recursive: true });
160
+ await mkdir(patchesRoot, { recursive: true });
161
+
162
+ for (const entry of entries) {
163
+ const normalizedPath = normalizeRelativePath(entry?.path);
164
+ if (!normalizedPath) {
165
+ continue;
166
+ }
167
+
168
+ const absoluteSourcePath = sanitizeVaultPath(this.vaultDir, normalizedPath);
169
+ const relativeBackupPath = `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/files/${normalizedPath}`;
170
+ const relativeStagedPatchPath = entry?.stagedPatchContent
171
+ ? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.staged.patch`
172
+ : null;
173
+ const relativeWorktreePatchPath = entry?.worktreePatchContent
174
+ ? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.worktree.patch`
175
+ : null;
176
+ let backupPath = null;
177
+
178
+ if (absoluteSourcePath) {
179
+ try {
180
+ await stat(absoluteSourcePath);
181
+ const absoluteBackupPath = resolve(this.vaultDir, relativeBackupPath);
182
+ await mkdir(dirname(absoluteBackupPath), { recursive: true });
183
+ await copyFile(absoluteSourcePath, absoluteBackupPath);
184
+ backupPath = normalizeRelativePath(relativeBackupPath);
185
+ } catch (error) {
186
+ if (error?.code !== 'ENOENT') {
187
+ throw error;
188
+ }
189
+ }
190
+ }
191
+
192
+ if (relativeStagedPatchPath) {
193
+ const absolutePatchPath = resolve(this.vaultDir, relativeStagedPatchPath);
194
+ await mkdir(dirname(absolutePatchPath), { recursive: true });
195
+ await writeFile(absolutePatchPath, entry.stagedPatchContent, 'utf8');
196
+ }
197
+
198
+ if (relativeWorktreePatchPath) {
199
+ const absolutePatchPath = resolve(this.vaultDir, relativeWorktreePatchPath);
200
+ await mkdir(dirname(absolutePatchPath), { recursive: true });
201
+ await writeFile(absolutePatchPath, entry.worktreePatchContent, 'utf8');
202
+ }
203
+
204
+ backupEntries.push({
205
+ backupPath,
206
+ oldPath: normalizeRelativePath(entry?.oldPath ?? ''),
207
+ path: normalizedPath,
208
+ stagedPatchPath: relativeStagedPatchPath ? normalizeRelativePath(relativeStagedPatchPath) : null,
209
+ worktreePatchPath: relativeWorktreePatchPath ? normalizeRelativePath(relativeWorktreePatchPath) : null,
210
+ });
211
+ }
212
+
213
+ const fileCount = backupEntries.filter((entry) => (
214
+ entry.backupPath
215
+ || entry.stagedPatchPath
216
+ || entry.worktreePatchPath
217
+ )).length;
218
+ const summaryPath = this.getSummaryPath(backupId);
219
+ await writeFile(
220
+ resolve(this.vaultDir, summaryPath),
221
+ formatSummary({
222
+ backupEntries,
223
+ backupId,
224
+ branch,
225
+ createdAt,
226
+ fileCount,
227
+ headRef,
228
+ targetRef,
229
+ }),
230
+ 'utf8',
231
+ );
232
+
233
+ return {
234
+ branch,
235
+ createdAt,
236
+ fileCount,
237
+ id: backupId,
238
+ summaryPath,
239
+ };
240
+ }
241
+
242
+ async listBackups() {
243
+ let entries = [];
244
+ try {
245
+ entries = await readdir(this.getStorageRoot(), { withFileTypes: true });
246
+ } catch (error) {
247
+ if (error?.code === 'ENOENT') {
248
+ return [];
249
+ }
250
+ throw error;
251
+ }
252
+
253
+ const backups = await Promise.all(entries
254
+ .filter((entry) => entry.isDirectory())
255
+ .map(async (entry) => {
256
+ const summaryPath = this.getSummaryPath(entry.name);
257
+ try {
258
+ const content = await readFile(resolve(this.vaultDir, summaryPath), 'utf8');
259
+ const metadata = parseBackupMetadata(content);
260
+ if (!metadata) {
261
+ return null;
262
+ }
263
+
264
+ return {
265
+ branch: metadata.branch ?? null,
266
+ createdAt: metadata.createdAt ?? null,
267
+ fileCount: Number(metadata.fileCount ?? 0) || 0,
268
+ id: entry.name,
269
+ summaryPath,
270
+ };
271
+ } catch (error) {
272
+ if (error?.code === 'ENOENT') {
273
+ return null;
274
+ }
275
+ throw error;
276
+ }
277
+ }));
278
+
279
+ return backups
280
+ .filter(Boolean)
281
+ .sort((left, right) => String(right.createdAt || '').localeCompare(String(left.createdAt || '')));
282
+ }
283
+ }
@@ -1,7 +1,12 @@
1
1
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
2
- import { dirname, join, resolve } from 'path';
2
+ import { basename, dirname, extname, join, relative, resolve } from 'path';
3
3
 
4
- import { getVaultTreeNodeType, isVaultFilePath } from '../../../domain/file-kind.js';
4
+ import {
5
+ getVaultTreeNodeType,
6
+ isImageAttachmentFilePath,
7
+ isMarkdownFilePath,
8
+ isVaultFilePath,
9
+ } from '../../../domain/file-kind.js';
5
10
  import { getVaultContentAdapter } from './vault-content-adapter.js';
6
11
  import {
7
12
  INVALID_VAULT_FILE_PATH_ERROR,
@@ -14,6 +19,23 @@ import {
14
19
  } from './path-utils.js';
15
20
  import { SidecarStore } from './sidecar-store.js';
16
21
 
22
+ const IMAGE_EXTENSION_TO_MIME_TYPE = Object.freeze({
23
+ '.gif': 'image/gif',
24
+ '.jpeg': 'image/jpeg',
25
+ '.jpg': 'image/jpeg',
26
+ '.png': 'image/png',
27
+ '.svg': 'image/svg+xml',
28
+ '.webp': 'image/webp',
29
+ });
30
+
31
+ const MIME_TYPE_TO_IMAGE_EXTENSION = Object.freeze({
32
+ 'image/gif': '.gif',
33
+ 'image/jpeg': '.jpg',
34
+ 'image/png': '.png',
35
+ 'image/svg+xml': '.svg',
36
+ 'image/webp': '.webp',
37
+ });
38
+
17
39
  function createTransactionalPath(targetPath, label) {
18
40
  return `${targetPath}.collabmd-${label}-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
19
41
  }
@@ -42,6 +64,88 @@ async function cleanupPaths(paths = []) {
42
64
  await Promise.allSettled(paths.filter(Boolean).map((pathValue) => rm(pathValue, { force: true })));
43
65
  }
44
66
 
67
+ function normalizeAttachmentMimeType(value) {
68
+ return String(value ?? '').split(';')[0].trim().toLowerCase();
69
+ }
70
+
71
+ function sanitizeAttachmentStem(value, fallback = 'image') {
72
+ const normalized = String(value ?? '')
73
+ .trim()
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9]+/g, '-')
76
+ .replace(/^-+|-+$/g, '');
77
+ return normalized || fallback;
78
+ }
79
+
80
+ function padAttachmentTimestamp(value) {
81
+ return String(value).padStart(2, '0');
82
+ }
83
+
84
+ function createAttachmentTimestamp(date = new Date()) {
85
+ return [
86
+ date.getFullYear(),
87
+ padAttachmentTimestamp(date.getMonth() + 1),
88
+ padAttachmentTimestamp(date.getDate()),
89
+ ].join('')
90
+ + '-'
91
+ + [
92
+ padAttachmentTimestamp(date.getHours()),
93
+ padAttachmentTimestamp(date.getMinutes()),
94
+ padAttachmentTimestamp(date.getSeconds()),
95
+ ].join('');
96
+ }
97
+
98
+ function createDocumentAttachmentDirectoryPath(documentPath) {
99
+ const normalizedPath = String(documentPath ?? '').replace(/\\/g, '/');
100
+ const documentDir = dirname(normalizedPath).replace(/\\/g, '/');
101
+ const documentStem = basename(normalizedPath, extname(normalizedPath));
102
+ return documentDir === '.'
103
+ ? `${documentStem}.assets`
104
+ : `${documentDir}/${documentStem}.assets`;
105
+ }
106
+
107
+ function createAttachmentAltText(originalFileName = '') {
108
+ const stem = basename(String(originalFileName ?? ''), extname(String(originalFileName ?? '')))
109
+ .replace(/[_-]+/g, ' ')
110
+ .replace(/\s+/g, ' ')
111
+ .trim();
112
+ return stem || 'Image';
113
+ }
114
+
115
+ function escapeMarkdownText(value = '') {
116
+ return String(value)
117
+ .replace(/\\/g, '\\\\')
118
+ .replace(/\[/g, '\\[')
119
+ .replace(/\]/g, '\\]')
120
+ .replace(/\r?\n/g, ' ');
121
+ }
122
+
123
+ function encodeMarkdownPath(pathValue = '') {
124
+ return String(pathValue)
125
+ .split('/')
126
+ .map((segment) => encodeURIComponent(segment))
127
+ .join('/');
128
+ }
129
+
130
+ function resolveAttachmentExtension({ mimeType, originalFileName }) {
131
+ const normalizedMimeType = normalizeAttachmentMimeType(mimeType);
132
+ const extensionFromName = extname(String(originalFileName ?? '')).toLowerCase();
133
+ if (extensionFromName && IMAGE_EXTENSION_TO_MIME_TYPE[extensionFromName]) {
134
+ const expectedMimeType = IMAGE_EXTENSION_TO_MIME_TYPE[extensionFromName];
135
+ if (!normalizedMimeType || expectedMimeType === normalizedMimeType) {
136
+ return extensionFromName;
137
+ }
138
+ }
139
+
140
+ return MIME_TYPE_TO_IMAGE_EXTENSION[normalizedMimeType] ?? '';
141
+ }
142
+
143
+ function createAttachmentMarkdownSnippet({ altText, documentPath, storedPath }) {
144
+ const relativePath = relative(dirname(documentPath), storedPath).replace(/\\/g, '/');
145
+ const encodedRelativePath = encodeMarkdownPath(relativePath || basename(storedPath));
146
+ return `![${escapeMarkdownText(altText)}](${encodedRelativePath})`;
147
+ }
148
+
45
149
  export class VaultFileStore {
46
150
  constructor({ vaultDir }) {
47
151
  this.vaultDir = resolve(vaultDir);
@@ -174,6 +278,28 @@ export class VaultFileStore {
174
278
  return this.readContentFile(filePath, 'plantuml');
175
279
  }
176
280
 
281
+ async readImageAttachmentFile(filePath) {
282
+ const absolute = this.resolveContentPath(filePath, { requireVaultFile: false });
283
+ if (!absolute || !isImageAttachmentFilePath(filePath)) {
284
+ return null;
285
+ }
286
+
287
+ try {
288
+ const content = await readFile(absolute);
289
+ return {
290
+ content,
291
+ mimeType: IMAGE_EXTENSION_TO_MIME_TYPE[extname(filePath).toLowerCase()] || 'application/octet-stream',
292
+ path: filePath,
293
+ };
294
+ } catch (error) {
295
+ if (error.code === 'ENOENT') {
296
+ return null;
297
+ }
298
+
299
+ throw error;
300
+ }
301
+ }
302
+
177
303
  async writeMarkdownFile(filePath, content, options = {}) {
178
304
  return this.writeContentFile(filePath, content, 'markdown', options);
179
305
  }
@@ -190,6 +316,65 @@ export class VaultFileStore {
190
316
  return this.writeContentFile(filePath, content, 'plantuml', options);
191
317
  }
192
318
 
319
+ async writeImageAttachmentForDocument(sourceDocumentPath, {
320
+ content,
321
+ mimeType,
322
+ originalFileName = '',
323
+ now = new Date(),
324
+ } = {}) {
325
+ if (!Buffer.isBuffer(content) || content.byteLength === 0) {
326
+ return { ok: false, error: 'Missing attachment content' };
327
+ }
328
+
329
+ if (!isMarkdownFilePath(sourceDocumentPath)) {
330
+ return { ok: false, error: 'Source document must be a markdown file' };
331
+ }
332
+
333
+ const extension = resolveAttachmentExtension({ mimeType, originalFileName });
334
+ if (!extension) {
335
+ return { ok: false, error: 'Unsupported image type' };
336
+ }
337
+
338
+ const stemSource = basename(String(originalFileName ?? ''), extname(String(originalFileName ?? '')));
339
+ const attachmentStem = sanitizeAttachmentStem(stemSource, 'image');
340
+ const attachmentDirPath = createDocumentAttachmentDirectoryPath(sourceDocumentPath);
341
+ const timestamp = createAttachmentTimestamp(now);
342
+ const baseFileName = `${attachmentStem}-${timestamp}`;
343
+ let collisionIndex = 0;
344
+ let storedPath = '';
345
+ let absolutePath = '';
346
+
347
+ do {
348
+ const suffix = collisionIndex > 0 ? `-${collisionIndex + 1}` : '';
349
+ storedPath = `${attachmentDirPath}/${baseFileName}${suffix}${extension}`;
350
+ absolutePath = this.resolveContentPath(storedPath, { requireVaultFile: false });
351
+ collisionIndex += 1;
352
+ } while (absolutePath && await pathExists(absolutePath));
353
+
354
+ if (!absolutePath || !isImageAttachmentFilePath(storedPath)) {
355
+ return { ok: false, error: INVALID_VAULT_FILE_PATH_ERROR };
356
+ }
357
+
358
+ try {
359
+ await mkdir(dirname(absolutePath), { recursive: true });
360
+ await writeFile(absolutePath, content);
361
+ } catch (error) {
362
+ return { ok: false, error: error.message };
363
+ }
364
+
365
+ const altText = createAttachmentAltText(originalFileName);
366
+ return {
367
+ ok: true,
368
+ altText,
369
+ markdownSnippet: createAttachmentMarkdownSnippet({
370
+ altText,
371
+ documentPath: sourceDocumentPath,
372
+ storedPath,
373
+ }),
374
+ path: storedPath,
375
+ };
376
+ }
377
+
193
378
  async persistCollaborationState(filePath, {
194
379
  commentThreads = [],
195
380
  content = '',
@@ -397,6 +582,22 @@ export class VaultFileStore {
397
582
  ]);
398
583
  }
399
584
 
585
+ async reconcileCollaborationSnapshots({
586
+ changedPaths = [],
587
+ deletedPaths = [],
588
+ renamedPaths = [],
589
+ } = {}) {
590
+ const affectedPaths = new Set([
591
+ ...(changedPaths ?? []).filter(Boolean),
592
+ ...(deletedPaths ?? []).filter(Boolean),
593
+ ...((renamedPaths ?? []).flatMap((entry) => [entry?.oldPath, entry?.newPath]).filter(Boolean)),
594
+ ]);
595
+
596
+ await Promise.allSettled(
597
+ Array.from(affectedPaths, (filePath) => this.deleteCollaborationSnapshot(filePath)),
598
+ );
599
+ }
600
+
400
601
  async countFilesInDir(dirPath) {
401
602
  let count = 0;
402
603
 
@@ -1 +0,0 @@
1
- function o(e){let t=String(e??"").trim();return t?t.endsWith(".md")?t:`${t}.md`:null}function i(e,t){let r=o(e);if(!r||!Array.isArray(t)||t.length===0)return null;let a=String(e??"").trim();return t.find(n=>n===r||n.endsWith(`/${r}`)||n.replace(/\.md$/i,"")===a)??null}function u(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function l(e,t){return i(e,t)??void 0}function f(e,t,r){return Math.min(Math.max(e,t),r)}export{u as a,l as b,f as c};