collabmd 0.1.18 → 0.1.19

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 (52) hide show
  1. package/README.md +99 -17
  2. package/package.json +1 -1
  3. package/public/assets/css/style.css +1 -1
  4. package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
  5. package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
  6. package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
  7. package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +22 -0
  8. package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
  9. package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
  10. package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
  11. package/public/assets/js/excalidraw-editor.js +1 -1
  12. package/public/assets/js/main.js +59 -59
  13. package/public/assets/js/preview-render-worker.js +19 -19
  14. package/src/client/application/app-shell/presence-feature.js +2 -1
  15. package/src/client/application/app-shell/ui-feature.js +113 -1
  16. package/src/client/application/app-shell/workspace-feature.js +9 -0
  17. package/src/client/application/preview-render-compiler.js +55 -5
  18. package/src/client/application/preview-render-executor.js +8 -0
  19. package/src/client/application/preview-render-worker.js +13 -2
  20. package/src/client/application/preview-renderer.js +5 -0
  21. package/src/client/application/workspace-chrome-controller.js +12 -1
  22. package/src/client/application/workspace-coordinator.js +16 -7
  23. package/src/client/application/workspace-preview-controller.js +45 -5
  24. package/src/client/application/workspace-route-controller.js +4 -0
  25. package/src/client/bootstrap/collabmd-app-shell.js +15 -3
  26. package/src/client/domain/room.js +37 -0
  27. package/src/client/domain/vault-utils.js +46 -0
  28. package/src/client/infrastructure/comment-thread-store.js +98 -0
  29. package/src/client/infrastructure/editor-paste-utils.js +45 -0
  30. package/src/client/infrastructure/editor-session.js +10 -0
  31. package/src/client/infrastructure/editor-view-adapter.js +34 -1
  32. package/src/client/infrastructure/vault-api-client.js +17 -0
  33. package/src/client/presentation/comment-markdown-renderer.js +68 -0
  34. package/src/client/presentation/comment-ui-controller.js +311 -15
  35. package/src/client/presentation/file-explorer-controller.js +5 -0
  36. package/src/client/presentation/file-explorer-view.js +10 -2
  37. package/src/client/presentation/file-tree-state.js +7 -1
  38. package/src/client/presentation/image-lightbox-controller.js +394 -0
  39. package/src/client/styles/style.css +539 -14
  40. package/src/domain/comment-threads.js +95 -13
  41. package/src/domain/file-kind.js +16 -1
  42. package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
  43. package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
  44. package/src/server/infrastructure/http/request-body.js +11 -2
  45. package/src/server/infrastructure/persistence/path-utils.js +1 -1
  46. package/src/server/infrastructure/persistence/vault-file-store.js +187 -2
  47. package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
  48. package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
  49. package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
  50. package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
  51. package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
  52. package/public/assets/js/chunks/quick-switcher-controller-JYDIJVAJ.js +0 -5
@@ -3,6 +3,7 @@ import * as Y from 'yjs';
3
3
  export const COMMENT_BODY_MAX_LENGTH = 2000;
4
4
  export const COMMENT_EXCERPT_MAX_LENGTH = 160;
5
5
  export const COMMENT_ANCHOR_QUOTE_MAX_LENGTH = 280;
6
+ export const COMMENT_REACTION_EMOJI_MAX_LENGTH = 16;
6
7
 
7
8
  const COMMENT_ANCHOR_KINDS = new Set(['line', 'text']);
8
9
 
@@ -22,6 +23,14 @@ function asString(value) {
22
23
  return typeof value === 'string' ? value : '';
23
24
  }
24
25
 
26
+ function asArray(value) {
27
+ if (value instanceof Y.Array) {
28
+ return value.toArray();
29
+ }
30
+
31
+ return Array.isArray(value) ? value : [];
32
+ }
33
+
25
34
  function readThreadValue(thread, key) {
26
35
  if (thread instanceof Y.Map) {
27
36
  return thread.get(key);
@@ -34,6 +43,14 @@ function isResolvedThread(thread) {
34
43
  return asFiniteNumber(readThreadValue(thread, 'resolvedAt')) !== null;
35
44
  }
36
45
 
46
+ function readRecordValue(record, key) {
47
+ if (record instanceof Y.Map) {
48
+ return record.get(key);
49
+ }
50
+
51
+ return record?.[key];
52
+ }
53
+
37
54
  function normalizeAnchorKind(value) {
38
55
  return COMMENT_ANCHOR_KINDS.has(value) ? value : null;
39
56
  }
@@ -76,30 +93,95 @@ export function summarizeCommentExcerpt(value, maxLength = COMMENT_EXCERPT_MAX_L
76
93
  return `${normalized.slice(0, Math.max(maxLength - 1, 1)).trimEnd()}…`;
77
94
  }
78
95
 
96
+ function normalizeReactionEmoji(value) {
97
+ return Array.from(String(value ?? '').trim())
98
+ .slice(0, COMMENT_REACTION_EMOJI_MAX_LENGTH)
99
+ .join('');
100
+ }
101
+
102
+ function createReactionUserRecord(user) {
103
+ const userId = asString(readRecordValue(user, 'userId')).trim();
104
+ if (!userId) {
105
+ return null;
106
+ }
107
+
108
+ return {
109
+ reactedAt: asFiniteNumber(readRecordValue(user, 'reactedAt')) ?? Date.now(),
110
+ userColor: asString(readRecordValue(user, 'userColor')),
111
+ userId,
112
+ userName: asString(readRecordValue(user, 'userName')) || 'Anonymous',
113
+ };
114
+ }
115
+
116
+ function createReactionGroupRecord(group) {
117
+ const emoji = normalizeReactionEmoji(readRecordValue(group, 'emoji'));
118
+ if (!emoji) {
119
+ return null;
120
+ }
121
+
122
+ const usersById = new Map();
123
+ asArray(readRecordValue(group, 'users')).forEach((user) => {
124
+ const normalizedUser = createReactionUserRecord(user);
125
+ if (normalizedUser) {
126
+ usersById.set(normalizedUser.userId, normalizedUser);
127
+ }
128
+ });
129
+
130
+ if (usersById.size === 0) {
131
+ return null;
132
+ }
133
+
134
+ return {
135
+ emoji,
136
+ users: Array.from(usersById.values()),
137
+ };
138
+ }
139
+
140
+ function serializeCommentReactions(reactions) {
141
+ const groupsByEmoji = new Map();
142
+
143
+ asArray(reactions).forEach((group) => {
144
+ const normalizedGroup = createReactionGroupRecord(group);
145
+ if (!normalizedGroup) {
146
+ return;
147
+ }
148
+
149
+ const existing = groupsByEmoji.get(normalizedGroup.emoji);
150
+ if (!existing) {
151
+ groupsByEmoji.set(normalizedGroup.emoji, normalizedGroup);
152
+ return;
153
+ }
154
+
155
+ const mergedUsers = new Map(existing.users.map((user) => [user.userId, user]));
156
+ normalizedGroup.users.forEach((user) => mergedUsers.set(user.userId, user));
157
+ groupsByEmoji.set(normalizedGroup.emoji, {
158
+ emoji: normalizedGroup.emoji,
159
+ users: Array.from(mergedUsers.values()),
160
+ });
161
+ });
162
+
163
+ return Array.from(groupsByEmoji.values());
164
+ }
165
+
79
166
  function createMessageRecord(message) {
80
- const body = normalizeCommentBody(message?.body);
167
+ const body = normalizeCommentBody(readRecordValue(message, 'body'));
81
168
  if (!body) {
82
169
  return null;
83
170
  }
84
171
 
85
172
  return {
86
173
  body,
87
- createdAt: asFiniteNumber(message?.createdAt) ?? Date.now(),
88
- id: asString(message?.id) || createCommentId('comment'),
89
- peerId: asString(message?.peerId),
90
- userColor: asString(message?.userColor),
91
- userName: asString(message?.userName) || 'Anonymous',
174
+ createdAt: asFiniteNumber(readRecordValue(message, 'createdAt')) ?? Date.now(),
175
+ id: asString(readRecordValue(message, 'id')) || createCommentId('comment'),
176
+ peerId: asString(readRecordValue(message, 'peerId')),
177
+ reactions: serializeCommentReactions(readRecordValue(message, 'reactions')),
178
+ userColor: asString(readRecordValue(message, 'userColor')),
179
+ userName: asString(readRecordValue(message, 'userName')) || 'Anonymous',
92
180
  };
93
181
  }
94
182
 
95
183
  function serializeMessages(messages) {
96
- const source = messages instanceof Y.Array
97
- ? messages.toArray()
98
- : Array.isArray(messages)
99
- ? messages
100
- : [];
101
-
102
- return source
184
+ return asArray(messages)
103
185
  .map((message) => createMessageRecord(message))
104
186
  .filter(Boolean);
105
187
  }
@@ -2,6 +2,7 @@ const MARKDOWN_FILE_EXTENSIONS = Object.freeze(['.md', '.markdown', '.mdx']);
2
2
  const EXCALIDRAW_FILE_EXTENSION = '.excalidraw';
3
3
  const MERMAID_FILE_EXTENSIONS = Object.freeze(['.mmd', '.mermaid']);
4
4
  const PLANTUML_FILE_EXTENSIONS = Object.freeze(['.puml', '.plantuml']);
5
+ const IMAGE_ATTACHMENT_EXTENSIONS = Object.freeze(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
5
6
  const DIAGRAM_FILE_EXTENSIONS = Object.freeze([
6
7
  EXCALIDRAW_FILE_EXTENSION,
7
8
  ...MERMAID_FILE_EXTENSIONS,
@@ -10,8 +11,9 @@ const DIAGRAM_FILE_EXTENSIONS = Object.freeze([
10
11
  const VAULT_FILE_EXTENSIONS = Object.freeze([
11
12
  ...MARKDOWN_FILE_EXTENSIONS,
12
13
  ...DIAGRAM_FILE_EXTENSIONS,
14
+ ...IMAGE_ATTACHMENT_EXTENSIONS,
13
15
  ]);
14
- const STRIP_VAULT_EXTENSION_PATTERN = /\.(?:md|markdown|mdx|excalidraw|mmd|mermaid|puml|plantuml)$/i;
16
+ const STRIP_VAULT_EXTENSION_PATTERN = /\.(?:md|markdown|mdx|excalidraw|mmd|mermaid|puml|plantuml|png|jpe?g|webp|gif|svg)$/i;
15
17
 
16
18
  function normalizeFilePath(filePath) {
17
19
  return String(filePath ?? '').trim().toLowerCase();
@@ -25,6 +27,7 @@ function hasFileExtension(filePath, extensions) {
25
27
  export {
26
28
  DIAGRAM_FILE_EXTENSIONS,
27
29
  EXCALIDRAW_FILE_EXTENSION,
30
+ IMAGE_ATTACHMENT_EXTENSIONS,
28
31
  MARKDOWN_FILE_EXTENSIONS,
29
32
  MERMAID_FILE_EXTENSIONS,
30
33
  PLANTUML_FILE_EXTENSIONS,
@@ -48,6 +51,10 @@ export function getVaultFileKind(filePath) {
48
51
  return 'plantuml';
49
52
  }
50
53
 
54
+ if (hasFileExtension(filePath, IMAGE_ATTACHMENT_EXTENSIONS)) {
55
+ return 'image';
56
+ }
57
+
51
58
  return null;
52
59
  }
53
60
 
@@ -57,6 +64,10 @@ export function getVaultTreeNodeType(filePath) {
57
64
  return null;
58
65
  }
59
66
 
67
+ if (kind === 'image') {
68
+ return 'image';
69
+ }
70
+
60
71
  return kind === 'markdown' ? 'file' : kind;
61
72
  }
62
73
 
@@ -81,6 +92,10 @@ export function isPlantUmlFilePath(filePath) {
81
92
  return getVaultFileKind(filePath) === 'plantuml';
82
93
  }
83
94
 
95
+ export function isImageAttachmentFilePath(filePath) {
96
+ return getVaultFileKind(filePath) === 'image';
97
+ }
98
+
84
99
  export function isDiagramFilePath(filePath) {
85
100
  const kind = getVaultFileKind(filePath);
86
101
  return kind === 'excalidraw' || kind === 'mermaid' || kind === 'plantuml';
@@ -3,9 +3,22 @@ import {
3
3
  isMermaidFilePath,
4
4
  isPlantUmlFilePath,
5
5
  } from '../../../domain/file-kind.js';
6
- import { getRequestErrorStatusCode } from './http-errors.js';
6
+ import { createRequestError, getRequestErrorStatusCode } from './http-errors.js';
7
7
  import { jsonResponse } from './http-response.js';
8
- import { parseJsonBody } from './request-body.js';
8
+ import { parseJsonBody, readBinaryRequestBody } from './request-body.js';
9
+
10
+ function decodeHeaderMetadata(value) {
11
+ const normalized = String(value ?? '').trim();
12
+ if (!normalized) {
13
+ return '';
14
+ }
15
+
16
+ try {
17
+ return decodeURIComponent(normalized);
18
+ } catch {
19
+ throw createRequestError(400, 'Invalid attachment metadata header encoding');
20
+ }
21
+ }
9
22
 
10
23
  function selectWriteOperation(vaultFileStore, filePath, content) {
11
24
  if (isExcalidrawFilePath(filePath)) {
@@ -62,6 +75,39 @@ export function createVaultApiCommandHandler({
62
75
  return true;
63
76
  }
64
77
 
78
+ if (requestUrl.pathname === '/api/attachments' && req.method === 'POST') {
79
+ try {
80
+ const sourceDocumentPath = decodeHeaderMetadata(req.headers['x-collabmd-source-path']);
81
+ const originalFileName = decodeHeaderMetadata(req.headers['x-collabmd-file-name']);
82
+ const mimeType = String(req.headers['content-type'] || '').trim();
83
+
84
+ if (!sourceDocumentPath) {
85
+ jsonResponse(req, res, 400, { error: 'Missing source document path' });
86
+ return true;
87
+ }
88
+
89
+ const content = await readBinaryRequestBody(req);
90
+ const result = await vaultFileStore.writeImageAttachmentForDocument(sourceDocumentPath, {
91
+ content,
92
+ mimeType,
93
+ originalFileName,
94
+ });
95
+ if (!result.ok) {
96
+ jsonResponse(req, res, 400, { error: result.error });
97
+ return true;
98
+ }
99
+
100
+ jsonResponse(req, res, 201, {
101
+ markdown: result.markdownSnippet,
102
+ ok: true,
103
+ path: result.path,
104
+ });
105
+ } catch (error) {
106
+ handleVaultError(req, res, error, '[api] Failed to upload attachment:', 'Failed to upload attachment');
107
+ }
108
+ return true;
109
+ }
110
+
65
111
  if (requestUrl.pathname === '/api/file' && req.method === 'POST') {
66
112
  try {
67
113
  const body = await parseJsonBody(req);
@@ -1,9 +1,44 @@
1
+ import { basename } from 'node:path';
2
+
1
3
  import {
2
4
  isExcalidrawFilePath,
5
+ isImageAttachmentFilePath,
3
6
  isMermaidFilePath,
4
7
  isPlantUmlFilePath,
5
8
  } from '../../../domain/file-kind.js';
6
- import { jsonResponse } from './http-response.js';
9
+ import { jsonResponse, sendResponse } from './http-response.js';
10
+
11
+ const SVG_MIME_TYPE = 'image/svg+xml';
12
+ const SVG_ATTACHMENT_CSP = "default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'; sandbox";
13
+
14
+ function encodeContentDispositionFilename(fileName) {
15
+ return encodeURIComponent(String(fileName ?? ''))
16
+ .replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
17
+ }
18
+
19
+ function createSafeAsciiFilename(fileName) {
20
+ const fallback = String(fileName ?? '')
21
+ .replace(/[^\x20-\x7E]+/g, '_')
22
+ .replace(/["\\]/g, '_')
23
+ .trim();
24
+ return fallback || 'attachment';
25
+ }
26
+
27
+ function createAttachmentHeaders(attachment) {
28
+ const fileName = basename(String(attachment?.path ?? 'attachment'));
29
+ const headers = {
30
+ 'Cache-Control': 'private, max-age=300, stale-while-revalidate=3600',
31
+ 'Content-Disposition': `inline; filename="${createSafeAsciiFilename(fileName)}"; filename*=UTF-8''${encodeContentDispositionFilename(fileName)}`,
32
+ 'Content-Type': attachment.mimeType,
33
+ 'X-Content-Type-Options': 'nosniff',
34
+ };
35
+
36
+ if (attachment?.mimeType === SVG_MIME_TYPE) {
37
+ headers['Content-Security-Policy'] = SVG_ATTACHMENT_CSP;
38
+ }
39
+
40
+ return headers;
41
+ }
7
42
 
8
43
  function selectReadOperation(vaultFileStore, filePath) {
9
44
  if (isExcalidrawFilePath(filePath)) {
@@ -58,6 +93,37 @@ export function createVaultApiQueryHandler({
58
93
  return true;
59
94
  }
60
95
 
96
+ if (requestUrl.pathname === '/api/attachment' && req.method === 'GET') {
97
+ const filePath = requestUrl.searchParams.get('path');
98
+ if (!filePath) {
99
+ jsonResponse(req, res, 400, { error: 'Missing path parameter' });
100
+ return true;
101
+ }
102
+
103
+ if (!isImageAttachmentFilePath(filePath)) {
104
+ jsonResponse(req, res, 400, { error: 'Unsupported attachment path' });
105
+ return true;
106
+ }
107
+
108
+ try {
109
+ const attachment = await vaultFileStore.readImageAttachmentFile(filePath);
110
+ if (!attachment) {
111
+ jsonResponse(req, res, 404, { error: 'Attachment not found' });
112
+ return true;
113
+ }
114
+
115
+ sendResponse(req, res, {
116
+ body: attachment.content,
117
+ headers: createAttachmentHeaders(attachment),
118
+ statusCode: 200,
119
+ });
120
+ } catch (error) {
121
+ console.error('[api] Failed to read attachment:', error.message);
122
+ jsonResponse(req, res, 500, { error: 'Failed to read attachment' });
123
+ }
124
+ return true;
125
+ }
126
+
61
127
  if (requestUrl.pathname === '/api/backlinks' && req.method === 'GET') {
62
128
  const filePath = requestUrl.searchParams.get('file');
63
129
  if (!filePath) {
@@ -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
 
@@ -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 = '',
@@ -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};