collabmd 0.1.33 → 0.1.34

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.
@@ -117,263 +117,285 @@ async function streamDirectoryArchive(req, res, {
117
117
  await responsePromise;
118
118
  }
119
119
 
120
- export function createVaultApiQueryHandler({
121
- baseQueryService = null,
122
- backlinkIndex,
123
- vaultFileStore,
124
- workspaceMutationCoordinator = null,
125
- }) {
126
- return async function handleVaultApiQuery(req, res, requestUrl) {
127
- if (requestUrl.pathname === '/api/base/query' && req.method === 'POST') {
128
- try {
129
- if (!baseQueryService?.query) {
130
- jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
131
- return true;
132
- }
133
-
134
- const body = await parseJsonBody(req);
135
- const result = await baseQueryService.query({
136
- activeFilePath: body?.activeFilePath ?? '',
137
- basePath: body?.path ?? '',
138
- search: body?.search ?? '',
139
- source: typeof body?.source === 'string' ? body.source : null,
140
- sourcePath: body?.sourcePath ?? '',
141
- view: body?.view ?? '',
142
- });
143
-
144
- jsonResponse(req, res, 200, { ok: true, result });
145
- } catch (error) {
146
- console.error('[api] Failed to query base:', error.message);
147
- jsonResponse(req, res, 400, { error: error.message || 'Failed to query base' });
148
- }
149
- return true;
150
- }
120
+ // --- Route handlers ---
151
121
 
152
- if (requestUrl.pathname === '/api/base/property-values' && req.method === 'POST') {
153
- try {
154
- if (!baseQueryService?.propertyValues) {
155
- jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
156
- return true;
157
- }
158
-
159
- const body = await parseJsonBody(req);
160
- const result = await baseQueryService.propertyValues({
161
- activeFilePath: body?.activeFilePath ?? '',
162
- basePath: body?.path ?? '',
163
- propertyId: body?.propertyId ?? '',
164
- query: body?.query ?? '',
165
- source: typeof body?.source === 'string' ? body.source : null,
166
- sourcePath: body?.sourcePath ?? '',
167
- view: body?.view ?? '',
168
- });
169
-
170
- jsonResponse(req, res, 200, { ok: true, result });
171
- } catch (error) {
172
- console.error('[api] Failed to read base property values:', error.message);
173
- jsonResponse(req, res, 400, { error: error.message || 'Failed to read base property values' });
174
- }
175
- return true;
122
+ async function handleBaseQuery(req, res, _requestUrl, { baseQueryService }) {
123
+ try {
124
+ if (!baseQueryService?.query) {
125
+ jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
126
+ return;
176
127
  }
177
128
 
178
- if (requestUrl.pathname === '/api/base/transform' && req.method === 'POST') {
179
- try {
180
- if (!baseQueryService?.transform) {
181
- jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
182
- return true;
183
- }
184
-
185
- const body = await parseJsonBody(req);
186
- const result = await baseQueryService.transform({
187
- activeFilePath: body?.activeFilePath ?? '',
188
- basePath: body?.path ?? '',
189
- mutation: body?.mutation ?? null,
190
- source: typeof body?.source === 'string' ? body.source : null,
191
- sourcePath: body?.sourcePath ?? '',
192
- view: body?.view ?? '',
193
- });
194
-
195
- jsonResponse(req, res, 200, { ok: true, result });
196
- } catch (error) {
197
- console.error('[api] Failed to transform base:', error.message);
198
- jsonResponse(req, res, 400, { error: error.message || 'Failed to transform base' });
199
- }
200
- return true;
129
+ const body = await parseJsonBody(req);
130
+ const result = await baseQueryService.query({
131
+ activeFilePath: body?.activeFilePath ?? '',
132
+ basePath: body?.path ?? '',
133
+ search: body?.search ?? '',
134
+ source: typeof body?.source === 'string' ? body.source : null,
135
+ sourcePath: body?.sourcePath ?? '',
136
+ view: body?.view ?? '',
137
+ });
138
+
139
+ jsonResponse(req, res, 200, { ok: true, result });
140
+ } catch (error) {
141
+ console.error('[api] Failed to query base:', error.message);
142
+ jsonResponse(req, res, 400, { error: error.message || 'Failed to query base' });
143
+ }
144
+ }
145
+
146
+ async function handleBasePropertyValues(req, res, _requestUrl, { baseQueryService }) {
147
+ try {
148
+ if (!baseQueryService?.propertyValues) {
149
+ jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
150
+ return;
201
151
  }
202
152
 
203
- if (requestUrl.pathname === '/api/base/export' && req.method === 'POST') {
204
- try {
205
- if (!baseQueryService?.query) {
206
- jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
207
- return true;
208
- }
209
-
210
- const body = await parseJsonBody(req);
211
- const result = await baseQueryService.query({
212
- activeFilePath: body?.activeFilePath ?? '',
213
- basePath: body?.path ?? '',
214
- includeCsv: true,
215
- search: body?.search ?? '',
216
- source: typeof body?.source === 'string' ? body.source : null,
217
- sourcePath: body?.sourcePath ?? '',
218
- view: body?.view ?? '',
219
- });
220
- const fileName = basename(String(body?.path || body?.sourcePath || 'base')).replace(/\.[^.]+$/u, '') || 'base';
221
- sendResponse(req, res, {
222
- body: result.csv,
223
- headers: {
224
- 'Cache-Control': 'no-store',
225
- 'Content-Disposition': `attachment; filename="${createSafeAsciiFilename(`${fileName}.csv`)}"; filename*=UTF-8''${encodeContentDispositionFilename(`${fileName}.csv`)}`,
226
- 'Content-Type': 'text/csv; charset=utf-8',
227
- 'X-Content-Type-Options': 'nosniff',
228
- },
229
- statusCode: 200,
230
- });
231
- } catch (error) {
232
- console.error('[api] Failed to export base CSV:', error.message);
233
- jsonResponse(req, res, 400, { error: error.message || 'Failed to export base CSV' });
234
- }
235
- return true;
153
+ const body = await parseJsonBody(req);
154
+ const result = await baseQueryService.propertyValues({
155
+ activeFilePath: body?.activeFilePath ?? '',
156
+ basePath: body?.path ?? '',
157
+ propertyId: body?.propertyId ?? '',
158
+ query: body?.query ?? '',
159
+ source: typeof body?.source === 'string' ? body.source : null,
160
+ sourcePath: body?.sourcePath ?? '',
161
+ view: body?.view ?? '',
162
+ });
163
+
164
+ jsonResponse(req, res, 200, { ok: true, result });
165
+ } catch (error) {
166
+ console.error('[api] Failed to read base property values:', error.message);
167
+ jsonResponse(req, res, 400, { error: error.message || 'Failed to read base property values' });
168
+ }
169
+ }
170
+
171
+ async function handleBaseTransform(req, res, _requestUrl, { baseQueryService }) {
172
+ try {
173
+ if (!baseQueryService?.transform) {
174
+ jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
175
+ return;
236
176
  }
237
177
 
238
- if (requestUrl.pathname === '/api/files' && req.method === 'GET') {
239
- try {
240
- const tree = workspaceMutationCoordinator?.getWorkspaceTree?.() ?? await vaultFileStore.tree();
241
- jsonResponse(req, res, 200, { tree });
242
- } catch (error) {
243
- console.error('[api] Failed to read file tree:', error.message);
244
- jsonResponse(req, res, 500, { error: 'Failed to read file tree' });
245
- }
246
- return true;
178
+ const body = await parseJsonBody(req);
179
+ const result = await baseQueryService.transform({
180
+ activeFilePath: body?.activeFilePath ?? '',
181
+ basePath: body?.path ?? '',
182
+ mutation: body?.mutation ?? null,
183
+ source: typeof body?.source === 'string' ? body.source : null,
184
+ sourcePath: body?.sourcePath ?? '',
185
+ view: body?.view ?? '',
186
+ });
187
+
188
+ jsonResponse(req, res, 200, { ok: true, result });
189
+ } catch (error) {
190
+ console.error('[api] Failed to transform base:', error.message);
191
+ jsonResponse(req, res, 400, { error: error.message || 'Failed to transform base' });
192
+ }
193
+ }
194
+
195
+ async function handleBaseExport(req, res, _requestUrl, { baseQueryService }) {
196
+ try {
197
+ if (!baseQueryService?.query) {
198
+ jsonResponse(req, res, 503, { error: 'Bases query service is unavailable' });
199
+ return;
247
200
  }
248
201
 
249
- if (requestUrl.pathname === '/api/file' && req.method === 'GET') {
250
- const filePath = requestUrl.searchParams.get('path');
251
- if (!filePath) {
252
- jsonResponse(req, res, 400, { error: 'Missing path parameter' });
253
- return true;
254
- }
202
+ const body = await parseJsonBody(req);
203
+ const result = await baseQueryService.query({
204
+ activeFilePath: body?.activeFilePath ?? '',
205
+ basePath: body?.path ?? '',
206
+ includeCsv: true,
207
+ search: body?.search ?? '',
208
+ source: typeof body?.source === 'string' ? body.source : null,
209
+ sourcePath: body?.sourcePath ?? '',
210
+ view: body?.view ?? '',
211
+ });
212
+ const fileName = basename(String(body?.path || body?.sourcePath || 'base')).replace(/\.[^.]+$/u, '') || 'base';
213
+ sendResponse(req, res, {
214
+ body: result.csv,
215
+ headers: {
216
+ 'Cache-Control': 'no-store',
217
+ 'Content-Disposition': `attachment; filename="${createSafeAsciiFilename(`${fileName}.csv`)}"; filename*=UTF-8''${encodeContentDispositionFilename(`${fileName}.csv`)}`,
218
+ 'Content-Type': 'text/csv; charset=utf-8',
219
+ 'X-Content-Type-Options': 'nosniff',
220
+ },
221
+ statusCode: 200,
222
+ });
223
+ } catch (error) {
224
+ console.error('[api] Failed to export base CSV:', error.message);
225
+ jsonResponse(req, res, 400, { error: error.message || 'Failed to export base CSV' });
226
+ }
227
+ }
255
228
 
256
- try {
257
- const content = await selectReadOperation(vaultFileStore, filePath);
258
- if (content === null) {
259
- jsonResponse(req, res, 404, { error: 'File not found' });
260
- return true;
261
- }
262
-
263
- jsonResponse(req, res, 200, { path: filePath, content });
264
- } catch (error) {
265
- console.error('[api] Failed to read file:', error.message);
266
- jsonResponse(req, res, 500, { error: 'Failed to read file' });
267
- }
268
- return true;
229
+ async function handleFileTree(req, res, _requestUrl, { vaultFileStore, workspaceMutationCoordinator }) {
230
+ try {
231
+ const tree = workspaceMutationCoordinator?.getWorkspaceTree?.() ?? await vaultFileStore.tree();
232
+ jsonResponse(req, res, 200, { tree });
233
+ } catch (error) {
234
+ console.error('[api] Failed to read file tree:', error.message);
235
+ jsonResponse(req, res, 500, { error: 'Failed to read file tree' });
236
+ }
237
+ }
238
+
239
+ async function handleFileRead(req, res, requestUrl, { vaultFileStore }) {
240
+ const filePath = requestUrl.searchParams.get('path');
241
+ if (!filePath) {
242
+ jsonResponse(req, res, 400, { error: 'Missing path parameter' });
243
+ return;
244
+ }
245
+
246
+ try {
247
+ const content = await selectReadOperation(vaultFileStore, filePath);
248
+ if (content === null) {
249
+ jsonResponse(req, res, 404, { error: 'File not found' });
250
+ return;
269
251
  }
270
252
 
271
- if (requestUrl.pathname === '/api/download/file' && req.method === 'GET') {
272
- const filePath = requestUrl.searchParams.get('path');
273
- if (!filePath) {
274
- jsonResponse(req, res, 400, { error: 'Missing path parameter' });
275
- return true;
276
- }
253
+ jsonResponse(req, res, 200, { path: filePath, content });
254
+ } catch (error) {
255
+ console.error('[api] Failed to read file:', error.message);
256
+ jsonResponse(req, res, 500, { error: 'Failed to read file' });
257
+ }
258
+ }
277
259
 
278
- try {
279
- const download = await vaultFileStore.readDownloadFile(filePath);
280
- if (!download) {
281
- jsonResponse(req, res, 404, { error: 'File not found' });
282
- return true;
283
- }
284
-
285
- sendResponse(req, res, {
286
- body: download.content,
287
- headers: createDownloadHeaders(
288
- basename(String(download.path ?? 'download')),
289
- download.mimeType || 'application/octet-stream',
290
- ),
291
- statusCode: 200,
292
- });
293
- } catch (error) {
294
- console.error('[api] Failed to download file:', error.message);
295
- jsonResponse(req, res, 500, { error: 'Failed to download file' });
296
- }
297
- return true;
260
+ async function handleFileDownload(req, res, requestUrl, { vaultFileStore }) {
261
+ const filePath = requestUrl.searchParams.get('path');
262
+ if (!filePath) {
263
+ jsonResponse(req, res, 400, { error: 'Missing path parameter' });
264
+ return;
265
+ }
266
+
267
+ try {
268
+ const download = await vaultFileStore.readDownloadFile(filePath);
269
+ if (!download) {
270
+ jsonResponse(req, res, 404, { error: 'File not found' });
271
+ return;
298
272
  }
299
273
 
300
- if (requestUrl.pathname === '/api/download/directory' && req.method === 'GET') {
301
- const directoryPath = requestUrl.searchParams.get('path');
302
- if (!directoryPath) {
303
- jsonResponse(req, res, 400, { error: 'Missing path parameter' });
304
- return true;
305
- }
274
+ sendResponse(req, res, {
275
+ body: download.content,
276
+ headers: createDownloadHeaders(
277
+ basename(String(download.path ?? 'download')),
278
+ download.mimeType || 'application/octet-stream',
279
+ ),
280
+ statusCode: 200,
281
+ });
282
+ } catch (error) {
283
+ console.error('[api] Failed to download file:', error.message);
284
+ jsonResponse(req, res, 500, { error: 'Failed to download file' });
285
+ }
286
+ }
306
287
 
307
- try {
308
- const result = await vaultFileStore.listDirectoryEntriesForDownload(directoryPath);
309
- if (!result.ok) {
310
- jsonResponse(req, res, result.error === 'Directory not found' ? 404 : 400, { error: result.error });
311
- return true;
312
- }
313
-
314
- await streamDirectoryArchive(req, res, {
315
- entries: result.entries,
316
- rootName: result.rootName || 'archive',
317
- });
318
- } catch (error) {
319
- console.error('[api] Failed to download directory:', error.message);
320
- if (!res.headersSent) {
321
- jsonResponse(req, res, 500, { error: 'Failed to download directory' });
322
- } else {
323
- res.destroy(error);
324
- }
325
- }
326
- return true;
288
+ async function handleDirectoryDownload(req, res, requestUrl, { vaultFileStore }) {
289
+ const directoryPath = requestUrl.searchParams.get('path');
290
+ if (!directoryPath) {
291
+ jsonResponse(req, res, 400, { error: 'Missing path parameter' });
292
+ return;
293
+ }
294
+
295
+ try {
296
+ const result = await vaultFileStore.listDirectoryEntriesForDownload(directoryPath);
297
+ if (!result.ok) {
298
+ jsonResponse(req, res, result.error === 'Directory not found' ? 404 : 400, { error: result.error });
299
+ return;
327
300
  }
328
301
 
329
- if (requestUrl.pathname === '/api/attachment' && req.method === 'GET') {
330
- const filePath = requestUrl.searchParams.get('path');
331
- if (!filePath) {
332
- jsonResponse(req, res, 400, { error: 'Missing path parameter' });
333
- return true;
334
- }
302
+ await streamDirectoryArchive(req, res, {
303
+ entries: result.entries,
304
+ rootName: result.rootName || 'archive',
305
+ });
306
+ } catch (error) {
307
+ console.error('[api] Failed to download directory:', error.message);
308
+ if (!res.headersSent) {
309
+ jsonResponse(req, res, 500, { error: 'Failed to download directory' });
310
+ } else {
311
+ res.destroy(error);
312
+ }
313
+ }
314
+ }
335
315
 
336
- if (!isImageAttachmentFilePath(filePath)) {
337
- jsonResponse(req, res, 400, { error: 'Unsupported attachment path' });
338
- return true;
339
- }
316
+ async function handleAttachmentRead(req, res, requestUrl, { vaultFileStore }) {
317
+ const filePath = requestUrl.searchParams.get('path');
318
+ if (!filePath) {
319
+ jsonResponse(req, res, 400, { error: 'Missing path parameter' });
320
+ return;
321
+ }
340
322
 
341
- try {
342
- const attachment = await vaultFileStore.readImageAttachmentFile(filePath);
343
- if (!attachment) {
344
- jsonResponse(req, res, 404, { error: 'Attachment not found' });
345
- return true;
346
- }
347
-
348
- sendResponse(req, res, {
349
- body: attachment.content,
350
- headers: createAttachmentHeaders(attachment),
351
- statusCode: 200,
352
- });
353
- } catch (error) {
354
- console.error('[api] Failed to read attachment:', error.message);
355
- jsonResponse(req, res, 500, { error: 'Failed to read attachment' });
356
- }
357
- return true;
323
+ if (!isImageAttachmentFilePath(filePath)) {
324
+ jsonResponse(req, res, 400, { error: 'Unsupported attachment path' });
325
+ return;
326
+ }
327
+
328
+ try {
329
+ const attachment = await vaultFileStore.readImageAttachmentFile(filePath);
330
+ if (!attachment) {
331
+ jsonResponse(req, res, 404, { error: 'Attachment not found' });
332
+ return;
358
333
  }
359
334
 
360
- if (requestUrl.pathname === '/api/backlinks' && req.method === 'GET') {
361
- const filePath = requestUrl.searchParams.get('file');
362
- if (!filePath) {
363
- jsonResponse(req, res, 400, { error: 'Missing file parameter' });
364
- return true;
365
- }
335
+ sendResponse(req, res, {
336
+ body: attachment.content,
337
+ headers: createAttachmentHeaders(attachment),
338
+ statusCode: 200,
339
+ });
340
+ } catch (error) {
341
+ console.error('[api] Failed to read attachment:', error.message);
342
+ jsonResponse(req, res, 500, { error: 'Failed to read attachment' });
343
+ }
344
+ }
345
+
346
+ async function handleBacklinks(req, res, requestUrl, { backlinkIndex }) {
347
+ const filePath = requestUrl.searchParams.get('file');
348
+ if (!filePath) {
349
+ jsonResponse(req, res, 400, { error: 'Missing file parameter' });
350
+ return;
351
+ }
352
+
353
+ try {
354
+ jsonResponse(req, res, 200, {
355
+ backlinks: backlinkIndex ? await backlinkIndex.getBacklinks(filePath) : [],
356
+ file: filePath,
357
+ });
358
+ } catch (error) {
359
+ console.error('[api] Failed to get backlinks:', error.message);
360
+ jsonResponse(req, res, 500, { error: 'Failed to get backlinks' });
361
+ }
362
+ }
366
363
 
367
- try {
368
- jsonResponse(req, res, 200, {
369
- backlinks: backlinkIndex ? await backlinkIndex.getBacklinks(filePath) : [],
370
- file: filePath,
371
- });
372
- } catch (error) {
373
- console.error('[api] Failed to get backlinks:', error.message);
374
- jsonResponse(req, res, 500, { error: 'Failed to get backlinks' });
364
+ // --- Route table ---
365
+
366
+ function createRouteTable(context) {
367
+ return [
368
+ { method: 'POST', path: '/api/base/query', handler: handleBaseQuery },
369
+ { method: 'POST', path: '/api/base/property-values', handler: handleBasePropertyValues },
370
+ { method: 'POST', path: '/api/base/transform', handler: handleBaseTransform },
371
+ { method: 'POST', path: '/api/base/export', handler: handleBaseExport },
372
+ { method: 'GET', path: '/api/files', handler: handleFileTree },
373
+ { method: 'GET', path: '/api/file', handler: handleFileRead },
374
+ { method: 'GET', path: '/api/download/file', handler: handleFileDownload },
375
+ { method: 'GET', path: '/api/download/directory', handler: handleDirectoryDownload },
376
+ { method: 'GET', path: '/api/attachment', handler: handleAttachmentRead },
377
+ { method: 'GET', path: '/api/backlinks', handler: handleBacklinks },
378
+ ].map((route) => ({
379
+ ...route,
380
+ handler: (req, res, requestUrl) => route.handler(req, res, requestUrl, context),
381
+ }));
382
+ }
383
+
384
+ export function createVaultApiQueryHandler({
385
+ baseQueryService = null,
386
+ backlinkIndex,
387
+ vaultFileStore,
388
+ workspaceMutationCoordinator = null,
389
+ }) {
390
+ const context = { baseQueryService, backlinkIndex, vaultFileStore, workspaceMutationCoordinator };
391
+ const routes = createRouteTable(context);
392
+
393
+ return async function handleVaultApiQuery(req, res, requestUrl) {
394
+ for (const route of routes) {
395
+ if (req.method === route.method && requestUrl.pathname === route.path) {
396
+ await route.handler(req, res, requestUrl);
397
+ return true;
375
398
  }
376
- return true;
377
399
  }
378
400
 
379
401
  return false;
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readFile, readdir, rename, rm, rmdir, stat, writeFile } from 'fs/promises';
2
2
  import { basename, dirname, extname, join, relative, resolve } from 'path';
3
+ import sharp from 'sharp';
3
4
 
4
5
  import {
5
6
  getVaultFileKind,
@@ -38,6 +39,9 @@ const MIME_TYPE_TO_IMAGE_EXTENSION = Object.freeze({
38
39
  'image/svg+xml': '.svg',
39
40
  'image/webp': '.webp',
40
41
  });
42
+ const MAX_RASTER_ATTACHMENT_PIXELS = 40_000_000;
43
+ const RASTER_IMAGE_MIME_TYPES_TO_CONVERT = new Set(['image/jpeg', 'image/png']);
44
+ const RASTER_IMAGE_EXTENSIONS_TO_CONVERT = new Set(['.jpeg', '.jpg', '.png']);
41
45
  const TEXT_FILE_MIME_TYPES = Object.freeze({
42
46
  base: 'text/yaml; charset=utf-8',
43
47
  drawio: 'application/xml; charset=utf-8',
@@ -159,13 +163,8 @@ function createAttachmentTimestamp(date = new Date()) {
159
163
  ].join('');
160
164
  }
161
165
 
162
- function createDocumentAttachmentDirectoryPath(documentPath) {
163
- const normalizedPath = String(documentPath ?? '').replace(/\\/g, '/');
164
- const documentDir = dirname(normalizedPath).replace(/\\/g, '/');
165
- const documentStem = basename(normalizedPath, extname(normalizedPath));
166
- return documentDir === '.'
167
- ? `${documentStem}.assets`
168
- : `${documentDir}/${documentStem}.assets`;
166
+ function createDocumentAttachmentDirectoryPath() {
167
+ return 'assets';
169
168
  }
170
169
 
171
170
  function createAttachmentAltText(originalFileName = '') {
@@ -204,6 +203,59 @@ function resolveAttachmentExtension({ mimeType, originalFileName }) {
204
203
  return MIME_TYPE_TO_IMAGE_EXTENSION[normalizedMimeType] ?? '';
205
204
  }
206
205
 
206
+ async function prepareImageAttachmentForStorage({
207
+ content,
208
+ mimeType,
209
+ originalFileName,
210
+ }) {
211
+ const normalizedMimeType = normalizeAttachmentMimeType(mimeType);
212
+ const extension = resolveAttachmentExtension({
213
+ mimeType: normalizedMimeType,
214
+ originalFileName,
215
+ });
216
+
217
+ if (!extension) {
218
+ return { ok: false, error: 'Unsupported image type' };
219
+ }
220
+
221
+ const shouldConvertToWebp = RASTER_IMAGE_MIME_TYPES_TO_CONVERT.has(normalizedMimeType)
222
+ || (!normalizedMimeType && RASTER_IMAGE_EXTENSIONS_TO_CONVERT.has(extension));
223
+ if (!shouldConvertToWebp) {
224
+ return {
225
+ content,
226
+ extension,
227
+ ok: true,
228
+ };
229
+ }
230
+
231
+ try {
232
+ const image = sharp(content, {
233
+ failOn: 'error',
234
+ limitInputPixels: MAX_RASTER_ATTACHMENT_PIXELS,
235
+ });
236
+ const metadata = await image.metadata();
237
+ const pixelCount = Number(metadata.width || 0) * Number(metadata.height || 0);
238
+ if (!metadata.width || !metadata.height || pixelCount > MAX_RASTER_ATTACHMENT_PIXELS) {
239
+ return { ok: false, error: 'Image dimensions exceed limit' };
240
+ }
241
+
242
+ return {
243
+ content: await image
244
+ .rotate()
245
+ .webp()
246
+ .toBuffer(),
247
+ extension: '.webp',
248
+ ok: true,
249
+ };
250
+ } catch (error) {
251
+ if (String(error?.message || '').includes('pixel limit')) {
252
+ return { ok: false, error: 'Image dimensions exceed limit' };
253
+ }
254
+
255
+ return { ok: false, error: 'Failed to convert image to WebP' };
256
+ }
257
+ }
258
+
207
259
  function createAttachmentMarkdownSnippet({ altText, documentPath, storedPath }) {
208
260
  const relativePath = relative(dirname(documentPath), storedPath).replace(/\\/g, '/');
209
261
  const encodedRelativePath = encodeMarkdownPath(relativePath || basename(storedPath));
@@ -434,9 +486,13 @@ export class VaultFileStore {
434
486
  return { ok: false, error: 'Source document must be a markdown file' };
435
487
  }
436
488
 
437
- const extension = resolveAttachmentExtension({ mimeType, originalFileName });
438
- if (!extension) {
439
- return { ok: false, error: 'Unsupported image type' };
489
+ const preparedAttachment = await prepareImageAttachmentForStorage({
490
+ content,
491
+ mimeType,
492
+ originalFileName,
493
+ });
494
+ if (!preparedAttachment.ok) {
495
+ return { ok: false, error: preparedAttachment.error };
440
496
  }
441
497
 
442
498
  const stemSource = basename(String(originalFileName ?? ''), extname(String(originalFileName ?? '')));
@@ -450,7 +506,7 @@ export class VaultFileStore {
450
506
 
451
507
  do {
452
508
  const suffix = collisionIndex > 0 ? `-${collisionIndex + 1}` : '';
453
- storedPath = `${attachmentDirPath}/${baseFileName}${suffix}${extension}`;
509
+ storedPath = `${attachmentDirPath}/${baseFileName}${suffix}${preparedAttachment.extension}`;
454
510
  absolutePath = this.resolveContentPath(storedPath, { requireVaultFile: false });
455
511
  collisionIndex += 1;
456
512
  } while (absolutePath && await pathExists(absolutePath));
@@ -462,7 +518,7 @@ export class VaultFileStore {
462
518
  try {
463
519
  await this.runManagedWrite([storedPath], async () => {
464
520
  await mkdir(dirname(absolutePath), { recursive: true });
465
- await writeFile(absolutePath, content);
521
+ await writeFile(absolutePath, preparedAttachment.content);
466
522
  });
467
523
  } catch (error) {
468
524
  return { ok: false, error: error.message };