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
|
@@ -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';
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
export function createGitRequestError(statusCode, message) {
|
|
1
|
+
export function createGitRequestError(statusCode, message, requestCode = null) {
|
|
2
2
|
const error = new Error(message);
|
|
3
3
|
error.statusCode = statusCode;
|
|
4
|
+
if (requestCode) {
|
|
5
|
+
error.requestCode = requestCode;
|
|
6
|
+
}
|
|
4
7
|
return error;
|
|
5
8
|
}
|
|
@@ -6,6 +6,7 @@ import { parseNameStatusOutput } from './parsers.js';
|
|
|
6
6
|
import { createEmptyWorkspaceChange, createWorkspaceChange } from './responses.js';
|
|
7
7
|
import { GitStatusService } from './status-service.js';
|
|
8
8
|
import { GitUntrackedFileService } from './untracked-files.js';
|
|
9
|
+
import { PullBackupStore } from '../persistence/pull-backup-store.js';
|
|
9
10
|
|
|
10
11
|
export class GitService {
|
|
11
12
|
constructor({
|
|
@@ -36,6 +37,7 @@ export class GitService {
|
|
|
36
37
|
statusService: this.statusService,
|
|
37
38
|
untrackedFileService: this.untrackedFileService,
|
|
38
39
|
});
|
|
40
|
+
this.pullBackupStore = new PullBackupStore({ vaultDir });
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
async isGitRepo() {
|
|
@@ -55,6 +57,14 @@ export class GitService {
|
|
|
55
57
|
return this.statusService.getStatus(options);
|
|
56
58
|
}
|
|
57
59
|
|
|
60
|
+
async listPullBackups() {
|
|
61
|
+
if (!(await this.isGitRepo())) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return this.pullBackupStore.listBackups();
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
async stageFile(path) {
|
|
59
69
|
const normalizedPath = normalizeRelativeGitPath(path);
|
|
60
70
|
await this.commandRunner.execGit(['add', '-A', '--', normalizedPath]);
|
|
@@ -131,7 +141,45 @@ export class GitService {
|
|
|
131
141
|
}
|
|
132
142
|
|
|
133
143
|
const beforeRef = await this.getHeadRef();
|
|
134
|
-
|
|
144
|
+
await this.fetchUpstream(status.branch.upstream);
|
|
145
|
+
|
|
146
|
+
const targetRef = await this.resolveRef(status.branch.upstream);
|
|
147
|
+
if (!targetRef) {
|
|
148
|
+
throw createGitRequestError(409, 'Unable to resolve upstream branch for pull');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!(await this.canFastForwardTo(targetRef))) {
|
|
152
|
+
throw createGitRequestError(
|
|
153
|
+
409,
|
|
154
|
+
'Cannot pull because local and remote commits have diverged; fast-forward only pull is not possible.',
|
|
155
|
+
'pull_diverged_ff_only',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const dirtyEntries = this.collectDirtyEntries(status);
|
|
160
|
+
const upstreamWorkspaceChange = await this.createWorkspaceChangeFromRefs(beforeRef, targetRef);
|
|
161
|
+
const overlappingEntries = this.findOverlappingDirtyEntries({
|
|
162
|
+
dirtyEntries,
|
|
163
|
+
workspaceChange: upstreamWorkspaceChange,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let pullBackup = null;
|
|
167
|
+
if (overlappingEntries.length > 0) {
|
|
168
|
+
pullBackup = await this.backupAndClearOverlappingEntries({
|
|
169
|
+
beforeRef,
|
|
170
|
+
branchName: status.branch?.name ?? null,
|
|
171
|
+
entries: overlappingEntries,
|
|
172
|
+
targetRef,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let output;
|
|
177
|
+
try {
|
|
178
|
+
output = await this.commandRunner.execGit(['pull', '--ff-only', '--autostash']);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
throw await this.classifyPullError(error);
|
|
181
|
+
}
|
|
182
|
+
|
|
135
183
|
const afterRef = await this.getHeadRef();
|
|
136
184
|
this.invalidateStatusCache();
|
|
137
185
|
return {
|
|
@@ -139,6 +187,7 @@ export class GitService {
|
|
|
139
187
|
beforeRef,
|
|
140
188
|
ok: true,
|
|
141
189
|
output: output.trim(),
|
|
190
|
+
pullBackup,
|
|
142
191
|
workspaceChange: await this.createWorkspaceChangeFromRefs(beforeRef, afterRef),
|
|
143
192
|
};
|
|
144
193
|
}
|
|
@@ -202,6 +251,171 @@ export class GitService {
|
|
|
202
251
|
}
|
|
203
252
|
}
|
|
204
253
|
|
|
254
|
+
async resolveRef(ref) {
|
|
255
|
+
try {
|
|
256
|
+
const output = await this.commandRunner.execGit(['rev-parse', ref]);
|
|
257
|
+
return output.trim() || null;
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async fetchUpstream(upstreamRef) {
|
|
264
|
+
const remoteName = String(upstreamRef ?? '').split('/')[0] || null;
|
|
265
|
+
await this.commandRunner.execGit(remoteName
|
|
266
|
+
? ['fetch', '--prune', remoteName]
|
|
267
|
+
: ['fetch', '--prune']);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async canFastForwardTo(targetRef) {
|
|
271
|
+
const headRef = await this.getHeadRef();
|
|
272
|
+
if (!headRef || !targetRef || headRef === targetRef) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await this.commandRunner.execGit(['merge-base', '--is-ancestor', headRef, targetRef]);
|
|
278
|
+
return true;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error?.code === 1) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
collectDirtyEntries(status = {}) {
|
|
288
|
+
const entries = new Map();
|
|
289
|
+
for (const section of status.sections ?? []) {
|
|
290
|
+
for (const file of section.files ?? []) {
|
|
291
|
+
if (!file?.path) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const existing = entries.get(file.path) ?? {
|
|
296
|
+
hasStagedChanges: false,
|
|
297
|
+
hasWorkingTreeChanges: false,
|
|
298
|
+
hasTrackedChanges: false,
|
|
299
|
+
isUntracked: false,
|
|
300
|
+
oldPath: null,
|
|
301
|
+
path: file.path,
|
|
302
|
+
touchPaths: new Set(),
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
existing.isUntracked = existing.isUntracked || file.scope === 'untracked';
|
|
306
|
+
existing.hasStagedChanges = existing.hasStagedChanges || file.scope === 'staged';
|
|
307
|
+
existing.hasWorkingTreeChanges = existing.hasWorkingTreeChanges || file.scope === 'working-tree';
|
|
308
|
+
existing.hasTrackedChanges = existing.hasTrackedChanges || file.scope === 'staged' || file.scope === 'working-tree';
|
|
309
|
+
if (!existing.oldPath && file.oldPath) {
|
|
310
|
+
existing.oldPath = file.oldPath;
|
|
311
|
+
}
|
|
312
|
+
existing.touchPaths.add(file.path);
|
|
313
|
+
if (file.oldPath) {
|
|
314
|
+
existing.touchPaths.add(file.oldPath);
|
|
315
|
+
}
|
|
316
|
+
entries.set(file.path, existing);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return Array.from(entries.values());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
findOverlappingDirtyEntries({ dirtyEntries = [], workspaceChange = createEmptyWorkspaceChange() } = {}) {
|
|
324
|
+
const upstreamTouchedPaths = new Set([
|
|
325
|
+
...(workspaceChange.changedPaths ?? []),
|
|
326
|
+
...(workspaceChange.deletedPaths ?? []),
|
|
327
|
+
...((workspaceChange.renamedPaths ?? []).flatMap((entry) => [entry.oldPath, entry.newPath])),
|
|
328
|
+
].filter(Boolean));
|
|
329
|
+
|
|
330
|
+
return dirtyEntries.filter((entry) => Array.from(entry.touchPaths).some((path) => upstreamTouchedPaths.has(path)));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async createPatchForEntry(entry, { cached = false } = {}) {
|
|
334
|
+
const pathspecs = Array.from(entry?.touchPaths ?? []).filter(Boolean);
|
|
335
|
+
if (pathspecs.length === 0) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const output = await this.commandRunner.execGit([
|
|
340
|
+
'diff',
|
|
341
|
+
'--binary',
|
|
342
|
+
'--find-renames',
|
|
343
|
+
...(cached ? ['--cached'] : []),
|
|
344
|
+
'--',
|
|
345
|
+
...pathspecs,
|
|
346
|
+
]);
|
|
347
|
+
|
|
348
|
+
return output || null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async backupAndClearOverlappingEntries({
|
|
352
|
+
beforeRef,
|
|
353
|
+
branchName = null,
|
|
354
|
+
entries = [],
|
|
355
|
+
targetRef = null,
|
|
356
|
+
} = {}) {
|
|
357
|
+
const backupEntries = await Promise.all(entries.map(async (entry) => ({
|
|
358
|
+
...entry,
|
|
359
|
+
stagedPatchContent: entry.hasStagedChanges
|
|
360
|
+
? await this.createPatchForEntry(entry, { cached: true })
|
|
361
|
+
: null,
|
|
362
|
+
worktreePatchContent: entry.hasWorkingTreeChanges
|
|
363
|
+
? await this.createPatchForEntry(entry, { cached: false })
|
|
364
|
+
: null,
|
|
365
|
+
})));
|
|
366
|
+
|
|
367
|
+
const pullBackup = await this.pullBackupStore.createBackup({
|
|
368
|
+
branch: branchName,
|
|
369
|
+
entries: backupEntries,
|
|
370
|
+
headRef: beforeRef,
|
|
371
|
+
targetRef,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
for (const entry of backupEntries) {
|
|
375
|
+
if (entry.isUntracked && !entry.hasTrackedChanges) {
|
|
376
|
+
await this.commandRunner.execGit(['clean', '-f', '--', entry.path]);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
await this.restorePathToHead(entry.path);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return pullBackup;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async restorePathToHead(path) {
|
|
387
|
+
const normalizedPath = normalizeRelativeGitPath(path);
|
|
388
|
+
const sourceRef = 'HEAD';
|
|
389
|
+
const existsOnSource = await this.pathExistsAtRef(sourceRef, normalizedPath);
|
|
390
|
+
|
|
391
|
+
if (existsOnSource) {
|
|
392
|
+
await this.commandRunner.execGit(['restore', '--source', sourceRef, '--staged', '--worktree', '--', normalizedPath]);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await this.commandRunner.execGit(['rm', '-f', '--ignore-unmatch', '--', normalizedPath]);
|
|
397
|
+
await this.commandRunner.execGit(['clean', '-f', '--', normalizedPath]);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async hasConflictedStatus() {
|
|
401
|
+
const status = await this.getStatus({ force: true });
|
|
402
|
+
return (status.sections ?? [])
|
|
403
|
+
.flatMap((section) => section.files ?? [])
|
|
404
|
+
.some((file) => file?.status === 'conflicted');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async classifyPullError(error) {
|
|
408
|
+
if (await this.hasConflictedStatus()) {
|
|
409
|
+
return createGitRequestError(
|
|
410
|
+
409,
|
|
411
|
+
'Pull applied remote updates, but reapplying local changes caused conflicts. Review the conflicted files and the pull backup summary.',
|
|
412
|
+
'pull_conflicted_after_autostash',
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return error;
|
|
417
|
+
}
|
|
418
|
+
|
|
205
419
|
async createWorkspaceChangeFromRefs(beforeRef, afterRef) {
|
|
206
420
|
if (!afterRef || beforeRef === afterRef) {
|
|
207
421
|
return createEmptyWorkspaceChange();
|
|
@@ -16,7 +16,11 @@ async function parseRequiredBody(req, res, fieldName) {
|
|
|
16
16
|
function handleGitError(req, res, error, logMessage, fallbackMessage) {
|
|
17
17
|
const statusCode = getRequestErrorStatusCode(error);
|
|
18
18
|
if (statusCode) {
|
|
19
|
-
|
|
19
|
+
const payload = { error: error.message };
|
|
20
|
+
if (typeof error?.requestCode === 'string') {
|
|
21
|
+
payload.code = error.requestCode;
|
|
22
|
+
}
|
|
23
|
+
jsonResponse(req, res, statusCode, payload);
|
|
20
24
|
return true;
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -47,6 +51,7 @@ async function applyWorkspaceMutationEffects({
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
await vaultFileStore?.reconcileSidecars?.(workspaceChange);
|
|
54
|
+
await vaultFileStore?.reconcileCollaborationSnapshots?.(workspaceChange);
|
|
50
55
|
await backlinkIndex?.build?.();
|
|
51
56
|
await roomRegistry?.reconcileWorkspaceChange?.(workspaceChange);
|
|
52
57
|
return responsePayload;
|
|
@@ -21,7 +21,11 @@ function isTruthyParam(value) {
|
|
|
21
21
|
function handleGitError(req, res, error, message, fallback) {
|
|
22
22
|
const statusCode = getRequestErrorStatusCode(error);
|
|
23
23
|
if (statusCode) {
|
|
24
|
-
|
|
24
|
+
const payload = { error: error.message };
|
|
25
|
+
if (typeof error?.requestCode === 'string') {
|
|
26
|
+
payload.code = error.requestCode;
|
|
27
|
+
}
|
|
28
|
+
jsonResponse(req, res, statusCode, payload);
|
|
25
29
|
return true;
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -57,6 +61,17 @@ export function createGitApiQueryHandler({ gitService }) {
|
|
|
57
61
|
return true;
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
if (requestUrl.pathname === '/api/git/pull-backups' && req.method === 'GET') {
|
|
65
|
+
try {
|
|
66
|
+
jsonResponse(req, res, 200, {
|
|
67
|
+
backups: await gitService.listPullBackups(),
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
handleGitError(req, res, error, '[api] Failed to read pull backups:', 'Failed to read pull backups');
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
return false;
|
|
61
76
|
};
|
|
62
77
|
}
|
|
@@ -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) {
|