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.
- package/dist/client/assets/{index-Czg_KL-i.js → index-BLrtj9YM.js} +2 -2
- package/dist/client/assets/{main-DeowJOSp.js → main-CfSHaGes.js} +86 -86
- package/dist/client/index.html +1 -1
- package/package.json +2 -1
- package/src/client/application/app-shell/git-feature.js +44 -36
- package/src/client/application/app-shell/ui-feature-shell.js +84 -63
- package/src/client/presentation/excalidraw-embed-controller.js +4 -1
- package/src/client/presentation/file-history-view-controller.js +9 -1
- package/src/client/presentation/git-panel-controller.js +101 -91
- package/src/server/domain/backlink-index.js +41 -22
- package/src/server/infrastructure/http/create-request-handler.js +63 -28
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +275 -256
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +255 -233
- package/src/server/infrastructure/persistence/vault-file-store.js +68 -12
|
@@ -117,263 +117,285 @@ async function streamDirectoryArchive(req, res, {
|
|
|
117
117
|
await responsePromise;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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(
|
|
163
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
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 };
|