collabmd 0.1.19 → 0.1.21
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 +13 -11
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/chunk-GFDM7YCF.js +1 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +22 -0
- package/public/assets/js/chunks/{preview-render-compiler-UZ4SZDQQ.js → preview-render-compiler-6Y7TKXFJ.js} +1 -1
- package/public/assets/js/chunks/{quick-switcher-controller-J7I3CUET.js → quick-switcher-controller-A6SKH5HI.js} +1 -1
- package/public/assets/js/{excalidraw-editor-ZDYKMZOL.js → excalidraw-editor-TCIH6GJL.js} +1 -1
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +102 -75
- package/public/assets/js/preview-render-worker.js +15 -15
- package/public/index.html +14 -4
- package/src/client/application/app-shell/git-feature.js +86 -19
- package/src/client/application/app-shell/ui-feature.js +4 -0
- package/src/client/application/app-shell-elements.js +1 -0
- package/src/client/bootstrap/collabmd-app-shell.js +16 -0
- package/src/client/domain/vault-utils.js +2 -2
- package/src/client/excalidraw-editor.js +2 -1
- package/src/client/infrastructure/editor-session.js +4 -0
- package/src/client/infrastructure/editor-view-adapter.js +245 -7
- package/src/client/infrastructure/workspace-sync-client.js +324 -0
- package/src/client/presentation/backlinks-panel.js +157 -101
- package/src/client/presentation/comment-ui-controller.js +233 -10
- package/src/client/presentation/file-explorer-controller.js +15 -3
- package/src/client/presentation/file-explorer-view.js +152 -11
- package/src/client/presentation/git-panel-controller.js +73 -0
- package/src/client/presentation/outline-controller.js +25 -0
- package/src/client/styles/style.css +184 -9
- package/src/domain/wiki-link-resolver.js +16 -5
- package/src/domain/workspace-change.js +68 -0
- package/src/domain/workspace-room.js +3 -0
- package/src/server/create-app-server.js +41 -10
- package/src/server/domain/backlink-index.js +94 -1
- package/src/server/domain/collaboration/collaboration-room.js +191 -22
- package/src/server/domain/collaboration/room-registry.js +64 -10
- package/src/server/infrastructure/git/errors.js +4 -1
- package/src/server/infrastructure/git/git-service.js +215 -1
- package/src/server/infrastructure/git/responses.js +12 -19
- package/src/server/infrastructure/http/create-git-api-command-handler.js +58 -43
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-query-handler.js +16 -1
- package/src/server/infrastructure/http/create-request-handler.js +7 -0
- package/src/server/infrastructure/http/create-vault-api-command-handler.js +58 -14
- package/src/server/infrastructure/http/create-vault-api-handler.js +3 -0
- package/src/server/infrastructure/http/create-vault-api-query-handler.js +3 -1
- package/src/server/infrastructure/http/http-response.js +65 -28
- package/src/server/infrastructure/persistence/pull-backup-store.js +283 -0
- package/src/server/infrastructure/persistence/vault-file-store.js +179 -52
- package/src/server/infrastructure/workspace/file-system-sync-service.js +488 -0
- package/src/server/infrastructure/workspace/workspace-mutation-coordinator.js +551 -0
- package/public/assets/js/chunks/chunk-R3DDMJHH.js +0 -1
- package/public/assets/js/chunks/editor-session-AH6Z3MXW.js +0 -22
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
isMermaidFilePath,
|
|
4
4
|
isPlantUmlFilePath,
|
|
5
5
|
} from '../../../domain/file-kind.js';
|
|
6
|
+
import { createWorkspaceChange } from '../../../domain/workspace-change.js';
|
|
6
7
|
import { createRequestError, getRequestErrorStatusCode } from './http-errors.js';
|
|
7
8
|
import { jsonResponse } from './http-response.js';
|
|
8
9
|
import { parseJsonBody, readBinaryRequestBody } from './request-body.js';
|
|
@@ -36,6 +37,11 @@ function selectWriteOperation(vaultFileStore, filePath, content) {
|
|
|
36
37
|
return vaultFileStore.writeMarkdownFile(filePath, content);
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
function readRequestId(req) {
|
|
41
|
+
const value = String(req.headers['x-collabmd-request-id'] || '').trim();
|
|
42
|
+
return value ? value.slice(0, 120) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
function handleVaultError(req, res, error, logMessage, fallbackMessage) {
|
|
40
46
|
const statusCode = getRequestErrorStatusCode(error);
|
|
41
47
|
if (statusCode) {
|
|
@@ -49,9 +55,8 @@ function handleVaultError(req, res, error, logMessage, fallbackMessage) {
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export function createVaultApiCommandHandler({
|
|
52
|
-
backlinkIndex,
|
|
53
|
-
roomRegistry = null,
|
|
54
58
|
vaultFileStore,
|
|
59
|
+
workspaceMutationCoordinator = null,
|
|
55
60
|
}) {
|
|
56
61
|
return async function handleVaultApiCommand(req, res, requestUrl) {
|
|
57
62
|
if (requestUrl.pathname === '/api/file' && req.method === 'PUT') {
|
|
@@ -68,6 +73,15 @@ export function createVaultApiCommandHandler({
|
|
|
68
73
|
return true;
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
77
|
+
action: 'write-file',
|
|
78
|
+
origin: 'api',
|
|
79
|
+
requestId: readRequestId(req),
|
|
80
|
+
workspaceChange: createWorkspaceChange({
|
|
81
|
+
changedPaths: [body.path],
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
71
85
|
jsonResponse(req, res, 200, { ok: true });
|
|
72
86
|
} catch (error) {
|
|
73
87
|
handleVaultError(req, res, error, '[api] Failed to write file:', 'Failed to write file');
|
|
@@ -97,6 +111,15 @@ export function createVaultApiCommandHandler({
|
|
|
97
111
|
return true;
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
115
|
+
action: 'upload-attachment',
|
|
116
|
+
origin: 'api',
|
|
117
|
+
requestId: readRequestId(req),
|
|
118
|
+
workspaceChange: createWorkspaceChange({
|
|
119
|
+
changedPaths: [result.path],
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
|
|
100
123
|
jsonResponse(req, res, 201, {
|
|
101
124
|
markdown: result.markdownSnippet,
|
|
102
125
|
ok: true,
|
|
@@ -121,8 +144,14 @@ export function createVaultApiCommandHandler({
|
|
|
121
144
|
jsonResponse(req, res, 409, { error: result.error });
|
|
122
145
|
return true;
|
|
123
146
|
}
|
|
124
|
-
|
|
125
|
-
|
|
147
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
148
|
+
action: 'create-file',
|
|
149
|
+
origin: 'api',
|
|
150
|
+
requestId: readRequestId(req),
|
|
151
|
+
workspaceChange: createWorkspaceChange({
|
|
152
|
+
changedPaths: [body.path],
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
126
155
|
jsonResponse(req, res, 201, { ok: true, path: body.path });
|
|
127
156
|
} catch (error) {
|
|
128
157
|
handleVaultError(req, res, error, '[api] Failed to create file:', 'Failed to create file');
|
|
@@ -137,21 +166,22 @@ export function createVaultApiCommandHandler({
|
|
|
137
166
|
return true;
|
|
138
167
|
}
|
|
139
168
|
|
|
140
|
-
const activeRoom = roomRegistry?.get(filePath);
|
|
141
169
|
try {
|
|
142
|
-
activeRoom?.markDeleted?.();
|
|
143
170
|
const result = await vaultFileStore.deleteFile(filePath);
|
|
144
171
|
if (!result.ok) {
|
|
145
|
-
activeRoom?.unmarkDeleted?.();
|
|
146
172
|
jsonResponse(req, res, 400, { error: result.error });
|
|
147
173
|
return true;
|
|
148
174
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
176
|
+
action: 'delete-file',
|
|
177
|
+
origin: 'api',
|
|
178
|
+
requestId: readRequestId(req),
|
|
179
|
+
workspaceChange: createWorkspaceChange({
|
|
180
|
+
deletedPaths: [filePath],
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
152
183
|
jsonResponse(req, res, 200, { ok: true });
|
|
153
184
|
} catch (error) {
|
|
154
|
-
activeRoom?.unmarkDeleted?.();
|
|
155
185
|
console.error('[api] Failed to delete file:', error.message);
|
|
156
186
|
jsonResponse(req, res, 500, { error: 'Failed to delete file' });
|
|
157
187
|
}
|
|
@@ -171,9 +201,14 @@ export function createVaultApiCommandHandler({
|
|
|
171
201
|
jsonResponse(req, res, 400, { error: result.error });
|
|
172
202
|
return true;
|
|
173
203
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
204
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
205
|
+
action: 'rename-file',
|
|
206
|
+
origin: 'api',
|
|
207
|
+
requestId: readRequestId(req),
|
|
208
|
+
workspaceChange: createWorkspaceChange({
|
|
209
|
+
renamedPaths: [{ oldPath: body.oldPath, newPath: body.newPath }],
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
177
212
|
jsonResponse(req, res, 200, { ok: true, path: body.newPath });
|
|
178
213
|
} catch (error) {
|
|
179
214
|
handleVaultError(req, res, error, '[api] Failed to rename file:', 'Failed to rename file');
|
|
@@ -195,6 +230,15 @@ export function createVaultApiCommandHandler({
|
|
|
195
230
|
return true;
|
|
196
231
|
}
|
|
197
232
|
|
|
233
|
+
await workspaceMutationCoordinator?.apply?.({
|
|
234
|
+
action: 'create-directory',
|
|
235
|
+
origin: 'api',
|
|
236
|
+
requestId: readRequestId(req),
|
|
237
|
+
workspaceChange: createWorkspaceChange({
|
|
238
|
+
changedPaths: [body.path],
|
|
239
|
+
}),
|
|
240
|
+
});
|
|
241
|
+
|
|
198
242
|
jsonResponse(req, res, 201, { ok: true });
|
|
199
243
|
} catch (error) {
|
|
200
244
|
handleVaultError(req, res, error, '[api] Failed to create directory:', 'Failed to create directory');
|
|
@@ -7,15 +7,18 @@ export function createVaultApiHandler({
|
|
|
7
7
|
plantUmlRenderer = null,
|
|
8
8
|
roomRegistry = null,
|
|
9
9
|
vaultFileStore,
|
|
10
|
+
workspaceMutationCoordinator = null,
|
|
10
11
|
}) {
|
|
11
12
|
const handleVaultApiQuery = createVaultApiQueryHandler({
|
|
12
13
|
backlinkIndex,
|
|
13
14
|
vaultFileStore,
|
|
15
|
+
workspaceMutationCoordinator,
|
|
14
16
|
});
|
|
15
17
|
const handleVaultApiCommand = createVaultApiCommandHandler({
|
|
16
18
|
backlinkIndex,
|
|
17
19
|
roomRegistry,
|
|
18
20
|
vaultFileStore,
|
|
21
|
+
workspaceMutationCoordinator,
|
|
19
22
|
});
|
|
20
23
|
const handlePlantUmlApi = createPlantUmlApiHandler({
|
|
21
24
|
plantUmlRenderer,
|
|
@@ -59,11 +59,13 @@ function selectReadOperation(vaultFileStore, filePath) {
|
|
|
59
59
|
export function createVaultApiQueryHandler({
|
|
60
60
|
backlinkIndex,
|
|
61
61
|
vaultFileStore,
|
|
62
|
+
workspaceMutationCoordinator = null,
|
|
62
63
|
}) {
|
|
63
64
|
return async function handleVaultApiQuery(req, res, requestUrl) {
|
|
64
65
|
if (requestUrl.pathname === '/api/files' && req.method === 'GET') {
|
|
65
66
|
try {
|
|
66
|
-
|
|
67
|
+
const tree = workspaceMutationCoordinator?.getWorkspaceTree?.() ?? await vaultFileStore.tree();
|
|
68
|
+
jsonResponse(req, res, 200, { tree });
|
|
67
69
|
} catch (error) {
|
|
68
70
|
console.error('[api] Failed to read file tree:', error.message);
|
|
69
71
|
jsonResponse(req, res, 500, { error: 'Failed to read file tree' });
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
brotliCompress,
|
|
3
|
+
constants as zlibConstants,
|
|
4
|
+
gzip,
|
|
5
|
+
} from 'node:zlib';
|
|
2
6
|
|
|
3
7
|
const COMPRESSIBLE_CONTENT_TYPE_PATTERN = /^(?:text\/|application\/(?:javascript|json|xml)|image\/svg\+xml)/i;
|
|
4
8
|
const MIN_COMPRESSIBLE_BYTES = 1024;
|
|
@@ -41,9 +45,9 @@ function resolveCompressionEncoding(acceptEncodingHeader) {
|
|
|
41
45
|
return null;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function
|
|
48
|
+
function prepareBody(req, body, contentType) {
|
|
45
49
|
if (body === undefined || body === null) {
|
|
46
|
-
return { body: null,
|
|
50
|
+
return { body: null, encoding: null };
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
const bodyBuffer = Buffer.isBuffer(body)
|
|
@@ -54,27 +58,48 @@ function maybeCompressBody(req, body, contentType) {
|
|
|
54
58
|
bodyBuffer.byteLength < MIN_COMPRESSIBLE_BYTES
|
|
55
59
|
|| !COMPRESSIBLE_CONTENT_TYPE_PATTERN.test(String(contentType || ''))
|
|
56
60
|
) {
|
|
57
|
-
return { body: bodyBuffer,
|
|
61
|
+
return { body: bodyBuffer, encoding: null };
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
const encoding = resolveCompressionEncoding(req.headers['accept-encoding']);
|
|
61
65
|
if (!encoding) {
|
|
62
|
-
return { body: bodyBuffer,
|
|
66
|
+
return { body: bodyBuffer, encoding: null };
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
return { body: bodyBuffer, encoding };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function compressBody(bodyBuffer, encoding, callback) {
|
|
73
|
+
if (encoding === 'br') {
|
|
74
|
+
brotliCompress(bodyBuffer, {
|
|
67
75
|
params: {
|
|
68
76
|
[zlibConstants.BROTLI_PARAM_QUALITY]: 5,
|
|
69
77
|
},
|
|
70
|
-
})
|
|
71
|
-
|
|
78
|
+
}, callback);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
gzip(bodyBuffer, { level: 6 }, callback);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function writeResponseHead(res, statusCode, headers, bodyBuffer) {
|
|
86
|
+
const responseHeaders = { ...headers };
|
|
87
|
+
if (bodyBuffer) {
|
|
88
|
+
responseHeaders['Content-Length'] = String(bodyBuffer.byteLength);
|
|
89
|
+
}
|
|
72
90
|
|
|
73
|
-
|
|
74
|
-
|
|
91
|
+
res.writeHead(statusCode, responseHeaders);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writePreparedBody(req, res, statusCode, headers, bodyBuffer) {
|
|
95
|
+
writeResponseHead(res, statusCode, headers, bodyBuffer);
|
|
96
|
+
|
|
97
|
+
if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
|
|
98
|
+
res.end();
|
|
99
|
+
return;
|
|
75
100
|
}
|
|
76
101
|
|
|
77
|
-
|
|
102
|
+
res.end(bodyBuffer ?? undefined);
|
|
78
103
|
}
|
|
79
104
|
|
|
80
105
|
export function setHeaders(res, headers) {
|
|
@@ -116,30 +141,42 @@ export function sendResponse(req, res, {
|
|
|
116
141
|
statusCode = 200,
|
|
117
142
|
} = {}) {
|
|
118
143
|
const contentType = headers['Content-Type'] || headers['content-type'] || '';
|
|
119
|
-
const prepared =
|
|
144
|
+
const prepared = prepareBody(req, body, contentType);
|
|
120
145
|
|
|
121
146
|
if (body !== null) {
|
|
122
147
|
appendVaryHeader(res, 'Accept-Encoding');
|
|
123
148
|
}
|
|
124
149
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (prepared.compressed && prepared.encoding) {
|
|
128
|
-
responseHeaders['Content-Encoding'] = prepared.encoding;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (prepared.body) {
|
|
132
|
-
responseHeaders['Content-Length'] = String(prepared.body.byteLength);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
res.writeHead(statusCode, responseHeaders);
|
|
136
|
-
|
|
137
|
-
if (req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
|
|
138
|
-
res.end();
|
|
150
|
+
if (!prepared.body || !prepared.encoding || req.method === 'HEAD' || statusCode === 204 || statusCode === 304) {
|
|
151
|
+
writePreparedBody(req, res, statusCode, headers, prepared.body);
|
|
139
152
|
return;
|
|
140
153
|
}
|
|
141
154
|
|
|
142
|
-
|
|
155
|
+
compressBody(prepared.body, prepared.encoding, (error, compressedBody) => {
|
|
156
|
+
if (res.writableEnded || res.destroyed) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (error) {
|
|
161
|
+
console.error('[http] Failed to compress response body:', error.message);
|
|
162
|
+
if (!res.headersSent) {
|
|
163
|
+
writePreparedBody(req, res, statusCode, headers, prepared.body);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
res.destroy(error);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!compressedBody || compressedBody.byteLength >= prepared.body.byteLength) {
|
|
171
|
+
writePreparedBody(req, res, statusCode, headers, prepared.body);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
writePreparedBody(req, res, statusCode, {
|
|
176
|
+
...headers,
|
|
177
|
+
'Content-Encoding': prepared.encoding,
|
|
178
|
+
}, compressedBody);
|
|
179
|
+
});
|
|
143
180
|
}
|
|
144
181
|
|
|
145
182
|
export function jsonResponse(req, res, statusCode, data) {
|
|
@@ -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
|
+
}
|