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.
- package/README.md +99 -17
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{preview-render-compiler-WD3A76I6.js → chunk-5QRWYPYT.js} +18 -18
- package/public/assets/js/chunks/chunk-HEWVH67U.js +9 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +1 -0
- package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +22 -0
- package/public/assets/js/chunks/preview-render-compiler-UZ4SZDQQ.js +1 -0
- package/public/assets/js/chunks/quick-switcher-controller-J7I3CUET.js +5 -0
- package/public/assets/js/{excalidraw-editor-LKW2AZBU.js → excalidraw-editor-ZDYKMZOL.js} +33 -33
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +59 -59
- package/public/assets/js/preview-render-worker.js +19 -19
- package/src/client/application/app-shell/presence-feature.js +2 -1
- package/src/client/application/app-shell/ui-feature.js +113 -1
- package/src/client/application/app-shell/workspace-feature.js +9 -0
- package/src/client/application/preview-render-compiler.js +55 -5
- package/src/client/application/preview-render-executor.js +8 -0
- package/src/client/application/preview-render-worker.js +13 -2
- package/src/client/application/preview-renderer.js +5 -0
- package/src/client/application/workspace-chrome-controller.js +12 -1
- package/src/client/application/workspace-coordinator.js +16 -7
- package/src/client/application/workspace-preview-controller.js +45 -5
- package/src/client/application/workspace-route-controller.js +4 -0
- package/src/client/bootstrap/collabmd-app-shell.js +15 -3
- package/src/client/domain/room.js +37 -0
- package/src/client/domain/vault-utils.js +46 -0
- package/src/client/infrastructure/comment-thread-store.js +98 -0
- package/src/client/infrastructure/editor-paste-utils.js +45 -0
- package/src/client/infrastructure/editor-session.js +10 -0
- package/src/client/infrastructure/editor-view-adapter.js +34 -1
- package/src/client/infrastructure/vault-api-client.js +17 -0
- package/src/client/presentation/comment-markdown-renderer.js +68 -0
- package/src/client/presentation/comment-ui-controller.js +311 -15
- package/src/client/presentation/file-explorer-controller.js +5 -0
- package/src/client/presentation/file-explorer-view.js +10 -2
- package/src/client/presentation/file-tree-state.js +7 -1
- package/src/client/presentation/image-lightbox-controller.js +394 -0
- package/src/client/styles/style.css +539 -14
- package/src/domain/comment-threads.js +95 -13
- package/src/domain/file-kind.js +16 -1
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +48 -2
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +67 -1
- package/src/server/infrastructure/http/request-body.js +11 -2
- package/src/server/infrastructure/persistence/path-utils.js +1 -1
- package/src/server/infrastructure/persistence/vault-file-store.js +187 -2
- package/public/assets/js/chunks/chunk-BBHPYU2R.js +0 -1
- package/public/assets/js/chunks/chunk-OG2TNZEU.js +0 -9
- package/public/assets/js/chunks/chunk-QSTBGTWJ.js +0 -1
- package/public/assets/js/chunks/chunk-SR3U53EQ.js +0 -1
- package/public/assets/js/chunks/editor-session-IHFTYG5D.js +0 -22
- 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
|
|
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
|
|
88
|
-
id: asString(message
|
|
89
|
-
peerId: asString(message
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/domain/file-kind.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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 .
|
|
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 {
|
|
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 ``;
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}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};
|