collabmd 0.1.18 → 0.1.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -25
- package/docker-compose.yml +1 -1
- 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-TAV2VXTA.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 +100 -73
- package/public/assets/js/preview-render-worker.js +19 -19
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +29 -1
- 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/app-shell-elements.js +1 -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 +19 -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 +98 -7
- package/src/client/infrastructure/vault-api-client.js +17 -0
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-markdown-renderer.js +68 -0
- package/src/client/presentation/comment-ui-controller.js +352 -20
- 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/git-panel-controller.js +73 -0
- package/src/client/presentation/image-lightbox-controller.js +394 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +677 -21
- package/src/domain/comment-threads.js +95 -13
- package/src/domain/file-kind.js +16 -1
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/http/create-git-api-command-handler.js +6 -1
- package/src/server/infrastructure/http/create-git-api-query-handler.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/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +203 -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
|
@@ -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
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { sanitizeVaultPath } from './path-utils.js';
|
|
5
|
+
|
|
6
|
+
const PULL_BACKUP_STORAGE_ROOT = '.collabmd/pull-backups';
|
|
7
|
+
const BACKUP_METADATA_PREFIX = '<!-- collabmd-pull-backup ';
|
|
8
|
+
const BACKUP_METADATA_SUFFIX = ' -->';
|
|
9
|
+
|
|
10
|
+
function normalizeRelativePath(pathValue = '') {
|
|
11
|
+
return String(pathValue).replace(/\\/g, '/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createBackupId(headRef = null, createdAt = new Date().toISOString()) {
|
|
15
|
+
const timestamp = normalizeRelativePath(createdAt)
|
|
16
|
+
.replace(/[^0-9T]/g, '')
|
|
17
|
+
.replace('T', '-')
|
|
18
|
+
.replace(/Z$/u, '');
|
|
19
|
+
const shortHead = String(headRef ?? 'workspace').trim().slice(0, 7) || 'workspace';
|
|
20
|
+
return `${timestamp}-${shortHead}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatBackupMetadata(metadata = {}) {
|
|
24
|
+
return `${BACKUP_METADATA_PREFIX}${JSON.stringify(metadata)}${BACKUP_METADATA_SUFFIX}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseBackupMetadata(content = '') {
|
|
28
|
+
const firstLine = String(content).split(/\r?\n/u)[0] ?? '';
|
|
29
|
+
if (!firstLine.startsWith(BACKUP_METADATA_PREFIX) || !firstLine.endsWith(BACKUP_METADATA_SUFFIX)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(firstLine.slice(BACKUP_METADATA_PREFIX.length, -BACKUP_METADATA_SUFFIX.length));
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatSummary({
|
|
41
|
+
backupEntries = [],
|
|
42
|
+
backupId,
|
|
43
|
+
branch = null,
|
|
44
|
+
createdAt,
|
|
45
|
+
fileCount = 0,
|
|
46
|
+
headRef = null,
|
|
47
|
+
targetRef = null,
|
|
48
|
+
} = {}) {
|
|
49
|
+
const metadata = formatBackupMetadata({
|
|
50
|
+
backupId,
|
|
51
|
+
branch,
|
|
52
|
+
createdAt,
|
|
53
|
+
fileCount,
|
|
54
|
+
headRef,
|
|
55
|
+
targetRef,
|
|
56
|
+
});
|
|
57
|
+
const entryLines = backupEntries.length === 0
|
|
58
|
+
? ['No overlapping files could be copied from the worktree.']
|
|
59
|
+
: backupEntries.flatMap((entry) => {
|
|
60
|
+
const lines = [`- Original path: \`${entry.path}\``];
|
|
61
|
+
if (entry.backupPath) {
|
|
62
|
+
lines.push(` Saved copy: \`${entry.backupPath}\``);
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(' Saved copy: none (the file no longer existed in the worktree at backup time)');
|
|
65
|
+
}
|
|
66
|
+
if (entry.stagedPatchPath) {
|
|
67
|
+
lines.push(` Staged patch: \`${entry.stagedPatchPath}\``);
|
|
68
|
+
}
|
|
69
|
+
if (entry.worktreePatchPath) {
|
|
70
|
+
lines.push(` Worktree patch: \`${entry.worktreePatchPath}\``);
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
metadata,
|
|
77
|
+
'# Pull Backup',
|
|
78
|
+
'',
|
|
79
|
+
`Created: \`${createdAt}\``,
|
|
80
|
+
`Branch: \`${branch || 'HEAD'}\``,
|
|
81
|
+
`Base ref: \`${headRef || 'none'}\``,
|
|
82
|
+
`Pulled to: \`${targetRef || 'none'}\``,
|
|
83
|
+
`Files backed up: \`${fileCount}\``,
|
|
84
|
+
'',
|
|
85
|
+
'This backup was created because local dirty changes overlapped with incoming remote updates.',
|
|
86
|
+
'The working tree was updated to the remote version so the latest upstream content is visible immediately.',
|
|
87
|
+
'',
|
|
88
|
+
'## Saved Files',
|
|
89
|
+
'',
|
|
90
|
+
...entryLines,
|
|
91
|
+
'',
|
|
92
|
+
'## Recovery',
|
|
93
|
+
'',
|
|
94
|
+
'Compare the current file content against the saved copies above, then manually restore any changes you want to keep.',
|
|
95
|
+
'Use `git apply --cached <staged patch>` to inspect or restore staged/index-only changes on a safe branch.',
|
|
96
|
+
'Use `git apply <worktree patch>` to inspect or restore unstaged worktree changes on a safe branch.',
|
|
97
|
+
'',
|
|
98
|
+
].join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function ensureLocalGitExclude(vaultDir) {
|
|
102
|
+
const excludePath = resolve(vaultDir, '.git/info/exclude');
|
|
103
|
+
let existingContent = '';
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
existingContent = await readFile(excludePath, 'utf8');
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error?.code !== 'ENOENT') {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines = existingContent
|
|
114
|
+
.split(/\r?\n/u)
|
|
115
|
+
.map((line) => line.trim())
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
if (lines.includes('.collabmd') || lines.includes('.collabmd/')) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const prefix = existingContent.length > 0 && !existingContent.endsWith('\n')
|
|
122
|
+
? '\n'
|
|
123
|
+
: '';
|
|
124
|
+
await writeFile(excludePath, `${existingContent}${prefix}.collabmd/\n`, 'utf8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class PullBackupStore {
|
|
128
|
+
constructor({ vaultDir }) {
|
|
129
|
+
this.vaultDir = resolve(vaultDir);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getStorageRoot() {
|
|
133
|
+
return resolve(this.vaultDir, PULL_BACKUP_STORAGE_ROOT);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
getBackupPath(backupId) {
|
|
137
|
+
return resolve(this.getStorageRoot(), backupId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getSummaryPath(backupId) {
|
|
141
|
+
return `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/summary.md`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async createBackup({
|
|
145
|
+
branch = null,
|
|
146
|
+
createdAt = new Date().toISOString(),
|
|
147
|
+
entries = [],
|
|
148
|
+
headRef = null,
|
|
149
|
+
targetRef = null,
|
|
150
|
+
} = {}) {
|
|
151
|
+
await ensureLocalGitExclude(this.vaultDir);
|
|
152
|
+
|
|
153
|
+
const backupId = createBackupId(headRef, createdAt);
|
|
154
|
+
const backupDir = this.getBackupPath(backupId);
|
|
155
|
+
const filesRoot = join(backupDir, 'files');
|
|
156
|
+
const patchesRoot = join(backupDir, 'patches');
|
|
157
|
+
const backupEntries = [];
|
|
158
|
+
|
|
159
|
+
await mkdir(filesRoot, { recursive: true });
|
|
160
|
+
await mkdir(patchesRoot, { recursive: true });
|
|
161
|
+
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
const normalizedPath = normalizeRelativePath(entry?.path);
|
|
164
|
+
if (!normalizedPath) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const absoluteSourcePath = sanitizeVaultPath(this.vaultDir, normalizedPath);
|
|
169
|
+
const relativeBackupPath = `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/files/${normalizedPath}`;
|
|
170
|
+
const relativeStagedPatchPath = entry?.stagedPatchContent
|
|
171
|
+
? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.staged.patch`
|
|
172
|
+
: null;
|
|
173
|
+
const relativeWorktreePatchPath = entry?.worktreePatchContent
|
|
174
|
+
? `${PULL_BACKUP_STORAGE_ROOT}/${backupId}/patches/${normalizedPath}.worktree.patch`
|
|
175
|
+
: null;
|
|
176
|
+
let backupPath = null;
|
|
177
|
+
|
|
178
|
+
if (absoluteSourcePath) {
|
|
179
|
+
try {
|
|
180
|
+
await stat(absoluteSourcePath);
|
|
181
|
+
const absoluteBackupPath = resolve(this.vaultDir, relativeBackupPath);
|
|
182
|
+
await mkdir(dirname(absoluteBackupPath), { recursive: true });
|
|
183
|
+
await copyFile(absoluteSourcePath, absoluteBackupPath);
|
|
184
|
+
backupPath = normalizeRelativePath(relativeBackupPath);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error?.code !== 'ENOENT') {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (relativeStagedPatchPath) {
|
|
193
|
+
const absolutePatchPath = resolve(this.vaultDir, relativeStagedPatchPath);
|
|
194
|
+
await mkdir(dirname(absolutePatchPath), { recursive: true });
|
|
195
|
+
await writeFile(absolutePatchPath, entry.stagedPatchContent, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (relativeWorktreePatchPath) {
|
|
199
|
+
const absolutePatchPath = resolve(this.vaultDir, relativeWorktreePatchPath);
|
|
200
|
+
await mkdir(dirname(absolutePatchPath), { recursive: true });
|
|
201
|
+
await writeFile(absolutePatchPath, entry.worktreePatchContent, 'utf8');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
backupEntries.push({
|
|
205
|
+
backupPath,
|
|
206
|
+
oldPath: normalizeRelativePath(entry?.oldPath ?? ''),
|
|
207
|
+
path: normalizedPath,
|
|
208
|
+
stagedPatchPath: relativeStagedPatchPath ? normalizeRelativePath(relativeStagedPatchPath) : null,
|
|
209
|
+
worktreePatchPath: relativeWorktreePatchPath ? normalizeRelativePath(relativeWorktreePatchPath) : null,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const fileCount = backupEntries.filter((entry) => (
|
|
214
|
+
entry.backupPath
|
|
215
|
+
|| entry.stagedPatchPath
|
|
216
|
+
|| entry.worktreePatchPath
|
|
217
|
+
)).length;
|
|
218
|
+
const summaryPath = this.getSummaryPath(backupId);
|
|
219
|
+
await writeFile(
|
|
220
|
+
resolve(this.vaultDir, summaryPath),
|
|
221
|
+
formatSummary({
|
|
222
|
+
backupEntries,
|
|
223
|
+
backupId,
|
|
224
|
+
branch,
|
|
225
|
+
createdAt,
|
|
226
|
+
fileCount,
|
|
227
|
+
headRef,
|
|
228
|
+
targetRef,
|
|
229
|
+
}),
|
|
230
|
+
'utf8',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
branch,
|
|
235
|
+
createdAt,
|
|
236
|
+
fileCount,
|
|
237
|
+
id: backupId,
|
|
238
|
+
summaryPath,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async listBackups() {
|
|
243
|
+
let entries = [];
|
|
244
|
+
try {
|
|
245
|
+
entries = await readdir(this.getStorageRoot(), { withFileTypes: true });
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (error?.code === 'ENOENT') {
|
|
248
|
+
return [];
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const backups = await Promise.all(entries
|
|
254
|
+
.filter((entry) => entry.isDirectory())
|
|
255
|
+
.map(async (entry) => {
|
|
256
|
+
const summaryPath = this.getSummaryPath(entry.name);
|
|
257
|
+
try {
|
|
258
|
+
const content = await readFile(resolve(this.vaultDir, summaryPath), 'utf8');
|
|
259
|
+
const metadata = parseBackupMetadata(content);
|
|
260
|
+
if (!metadata) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
branch: metadata.branch ?? null,
|
|
266
|
+
createdAt: metadata.createdAt ?? null,
|
|
267
|
+
fileCount: Number(metadata.fileCount ?? 0) || 0,
|
|
268
|
+
id: entry.name,
|
|
269
|
+
summaryPath,
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error?.code === 'ENOENT') {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
return backups
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.sort((left, right) => String(right.createdAt || '').localeCompare(String(left.createdAt || '')));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'fs/promises';
|
|
2
|
-
import { dirname, join, resolve } from 'path';
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve } from 'path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
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 = '',
|
|
@@ -397,6 +582,22 @@ export class VaultFileStore {
|
|
|
397
582
|
]);
|
|
398
583
|
}
|
|
399
584
|
|
|
585
|
+
async reconcileCollaborationSnapshots({
|
|
586
|
+
changedPaths = [],
|
|
587
|
+
deletedPaths = [],
|
|
588
|
+
renamedPaths = [],
|
|
589
|
+
} = {}) {
|
|
590
|
+
const affectedPaths = new Set([
|
|
591
|
+
...(changedPaths ?? []).filter(Boolean),
|
|
592
|
+
...(deletedPaths ?? []).filter(Boolean),
|
|
593
|
+
...((renamedPaths ?? []).flatMap((entry) => [entry?.oldPath, entry?.newPath]).filter(Boolean)),
|
|
594
|
+
]);
|
|
595
|
+
|
|
596
|
+
await Promise.allSettled(
|
|
597
|
+
Array.from(affectedPaths, (filePath) => this.deleteCollaborationSnapshot(filePath)),
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
400
601
|
async countFilesInDir(dirPath) {
|
|
401
602
|
let count = 0;
|
|
402
603
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
function o(e){let t=String(e??"").trim();return t?t.endsWith(".md")?t:`${t}.md`:null}function i(e,t){let r=o(e);if(!r||!Array.isArray(t)||t.length===0)return null;let a=String(e??"").trim();return t.find(n=>n===r||n.endsWith(`/${r}`)||n.replace(/\.md$/i,"")===a)??null}function u(e){return String(e).replace(/&/g,"&").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};
|