mrmd-server 0.2.6 → 0.2.7
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/package.json +1 -2
- package/src/services.js +10 -10
- package/src/sync-manager.js +3 -3
- package/src/vendor/config.js +295 -0
- package/src/vendor/services/asset-service.js +332 -0
- package/src/vendor/services/file-service.js +520 -0
- package/src/vendor/services/languagetool-preferences-service.js +341 -0
- package/src/vendor/services/languagetool-service.js +763 -0
- package/src/vendor/services/project-service.js +439 -0
- package/src/vendor/services/runtime-preferences-service.js +603 -0
- package/src/vendor/services/runtime-service.js +1075 -0
- package/src/vendor/services/settings-service.js +715 -0
- package/src/vendor/utils/index.js +8 -0
- package/src/vendor/utils/network.js +116 -0
- package/src/vendor/utils/platform.js +472 -0
- package/src/vendor/utils/python.js +309 -0
- package/src/vendor/utils/uv-installer.js +299 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mrmd-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
"chokidar": "^3.6.0",
|
|
48
48
|
"fzf": "^0.5.2",
|
|
49
49
|
"mrmd-project": "^0.1.2",
|
|
50
|
-
"mrmd-electron": "^0.4.0",
|
|
51
50
|
"mrmd-sync": "^0.3.3"
|
|
52
51
|
}
|
|
53
52
|
}
|
package/src/services.js
CHANGED
|
@@ -1,38 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Re-export services from mrmd-electron
|
|
2
|
+
* Re-export services (vendored from mrmd-electron)
|
|
3
3
|
*
|
|
4
4
|
* These services are pure Node.js (no Electron dependencies)
|
|
5
|
-
* and
|
|
5
|
+
* and are bundled directly so mrmd-server works standalone.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export {
|
|
9
9
|
default as ProjectService,
|
|
10
|
-
} from '
|
|
10
|
+
} from './vendor/services/project-service.js';
|
|
11
11
|
|
|
12
12
|
export {
|
|
13
13
|
default as RuntimeService,
|
|
14
|
-
} from '
|
|
14
|
+
} from './vendor/services/runtime-service.js';
|
|
15
15
|
|
|
16
16
|
export {
|
|
17
17
|
default as FileService,
|
|
18
|
-
} from '
|
|
18
|
+
} from './vendor/services/file-service.js';
|
|
19
19
|
|
|
20
20
|
export {
|
|
21
21
|
default as AssetService,
|
|
22
|
-
} from '
|
|
22
|
+
} from './vendor/services/asset-service.js';
|
|
23
23
|
|
|
24
24
|
export {
|
|
25
25
|
default as SettingsService,
|
|
26
|
-
} from '
|
|
26
|
+
} from './vendor/services/settings-service.js';
|
|
27
27
|
|
|
28
28
|
export {
|
|
29
29
|
default as RuntimePreferencesService,
|
|
30
|
-
} from '
|
|
30
|
+
} from './vendor/services/runtime-preferences-service.js';
|
|
31
31
|
|
|
32
32
|
export {
|
|
33
33
|
default as LanguageToolService,
|
|
34
|
-
} from '
|
|
34
|
+
} from './vendor/services/languagetool-service.js';
|
|
35
35
|
|
|
36
36
|
export {
|
|
37
37
|
default as LanguageToolPreferencesService,
|
|
38
|
-
} from '
|
|
38
|
+
} from './vendor/services/languagetool-preferences-service.js';
|
package/src/sync-manager.js
CHANGED
|
@@ -15,9 +15,9 @@ import fs from 'fs';
|
|
|
15
15
|
import os from 'os';
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
17
|
|
|
18
|
-
// Import utilities from mrmd-electron
|
|
19
|
-
import { findFreePort, waitForPort, isProcessAlive } from '
|
|
20
|
-
import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from '
|
|
18
|
+
// Import utilities (vendored from mrmd-electron)
|
|
19
|
+
import { findFreePort, waitForPort, isProcessAlive } from './vendor/utils/index.js';
|
|
20
|
+
import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from './vendor/config.js';
|
|
21
21
|
|
|
22
22
|
// Relay bridge for cloud mode
|
|
23
23
|
import { RelayBridge } from './relay-bridge.js';
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized configuration for mrmd-electron
|
|
3
|
+
*
|
|
4
|
+
* All magic numbers, timeouts, paths, and other configuration values
|
|
5
|
+
* should be defined here for easy modification and documentation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import os from 'os';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import {
|
|
11
|
+
isWin,
|
|
12
|
+
getConfigDir,
|
|
13
|
+
getDataDir,
|
|
14
|
+
getUvInstallDir,
|
|
15
|
+
getSystemPythonPaths,
|
|
16
|
+
getUvPaths,
|
|
17
|
+
getCondaPaths,
|
|
18
|
+
} from './utils/platform.js';
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// PATHS
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* User configuration directory
|
|
26
|
+
* Windows: %APPDATA%/mrmd
|
|
27
|
+
* macOS: ~/Library/Application Support/mrmd
|
|
28
|
+
* Linux: ~/.config/mrmd (or XDG_CONFIG_HOME)
|
|
29
|
+
*/
|
|
30
|
+
export const CONFIG_DIR = getConfigDir();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recent files/venvs persistence file
|
|
34
|
+
*/
|
|
35
|
+
export const RECENT_FILE = path.join(CONFIG_DIR, 'recent.json');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* User settings file (API keys, model mappings, custom commands)
|
|
39
|
+
*/
|
|
40
|
+
export const SETTINGS_FILE = path.join(CONFIG_DIR, 'settings.json');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Legacy runtimes directory (for old-style runtime registration)
|
|
44
|
+
*/
|
|
45
|
+
export const RUNTIMES_DIR = path.join(os.homedir(), '.mrmd', 'runtimes');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Session registry directory (for new session service)
|
|
49
|
+
*/
|
|
50
|
+
export const SESSIONS_DIR = path.join(os.homedir(), '.mrmd', 'sessions');
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Asset directory name within projects
|
|
54
|
+
*/
|
|
55
|
+
export const ASSETS_DIR_NAME = '_assets';
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Asset manifest filename
|
|
59
|
+
*/
|
|
60
|
+
export const ASSET_MANIFEST_NAME = '.manifest.json';
|
|
61
|
+
|
|
62
|
+
// ============================================================================
|
|
63
|
+
// NETWORK
|
|
64
|
+
// ============================================================================
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Default host for all network servers
|
|
68
|
+
* Using 127.0.0.1 for security (localhost only)
|
|
69
|
+
* TODO: Make configurable for remote/container setups
|
|
70
|
+
*/
|
|
71
|
+
export const DEFAULT_HOST = '127.0.0.1';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Timeout for waiting for a port to become available (ms)
|
|
75
|
+
*/
|
|
76
|
+
export const PORT_WAIT_TIMEOUT = 10000;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Interval between port checks (ms)
|
|
80
|
+
*/
|
|
81
|
+
export const PORT_CHECK_INTERVAL = 200;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Socket connection timeout (ms)
|
|
85
|
+
*/
|
|
86
|
+
export const SOCKET_TIMEOUT = 500;
|
|
87
|
+
|
|
88
|
+
// ============================================================================
|
|
89
|
+
// SYNC SERVER
|
|
90
|
+
// ============================================================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Memory limit for sync server (MB)
|
|
94
|
+
* Limited to 512MB to fail fast instead of consuming all system memory
|
|
95
|
+
* and crashing unpredictably after hours. Better to restart early than
|
|
96
|
+
* lose hours of work.
|
|
97
|
+
*/
|
|
98
|
+
export const SYNC_SERVER_MEMORY_MB = 512;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Watchdog interval for periodic backups (ms)
|
|
102
|
+
*/
|
|
103
|
+
export const WATCHDOG_INTERVAL = 60000;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* WebSocket ping interval for connection health checks (ms)
|
|
107
|
+
*/
|
|
108
|
+
export const WEBSOCKET_PING_INTERVAL = 30000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* WebSocket pong timeout - if no pong received in this time,
|
|
112
|
+
* consider connection dead (ms)
|
|
113
|
+
*/
|
|
114
|
+
export const WEBSOCKET_PONG_TIMEOUT = 5000;
|
|
115
|
+
|
|
116
|
+
// ============================================================================
|
|
117
|
+
// FILE SCANNING
|
|
118
|
+
// ============================================================================
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Maximum directory depth for file scanning
|
|
122
|
+
*/
|
|
123
|
+
export const FILE_SCAN_MAX_DEPTH = 6;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Maximum directory depth for venv discovery
|
|
127
|
+
*/
|
|
128
|
+
export const VENV_SCAN_MAX_DEPTH = 4;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Maximum directory depth for project file scanning
|
|
132
|
+
*/
|
|
133
|
+
export const PROJECT_SCAN_MAX_DEPTH = 10;
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// LIMITS
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Maximum recent files to keep
|
|
141
|
+
*/
|
|
142
|
+
export const MAX_RECENT_FILES = 50;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Maximum recent venvs to keep
|
|
146
|
+
*/
|
|
147
|
+
export const MAX_RECENT_VENVS = 20;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Hash length for asset deduplication (characters)
|
|
151
|
+
*/
|
|
152
|
+
export const ASSET_HASH_LENGTH = 16;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Hash length for directory hashing (characters)
|
|
156
|
+
*/
|
|
157
|
+
export const DIR_HASH_LENGTH = 12;
|
|
158
|
+
|
|
159
|
+
// ============================================================================
|
|
160
|
+
// WINDOW
|
|
161
|
+
// ============================================================================
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Default window dimensions
|
|
165
|
+
*/
|
|
166
|
+
export const DEFAULT_WINDOW_WIDTH = 1000;
|
|
167
|
+
export const DEFAULT_WINDOW_HEIGHT = 750;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Default background color (dark theme)
|
|
171
|
+
*/
|
|
172
|
+
export const DEFAULT_BACKGROUND_COLOR = '#ffffff';
|
|
173
|
+
|
|
174
|
+
// ============================================================================
|
|
175
|
+
// PYTHON PATHS
|
|
176
|
+
// ============================================================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Common system Python paths to check
|
|
180
|
+
* Platform-aware: includes Windows paths on Windows, Unix paths on Unix
|
|
181
|
+
*/
|
|
182
|
+
export const SYSTEM_PYTHON_PATHS = getSystemPythonPaths();
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Common conda installation paths (relative to home)
|
|
186
|
+
* Platform-aware: uses backslashes on Windows
|
|
187
|
+
*/
|
|
188
|
+
export const CONDA_PATHS = getCondaPaths();
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Common uv installation paths
|
|
192
|
+
* Platform-aware: includes Windows paths on Windows
|
|
193
|
+
*/
|
|
194
|
+
export const UV_PATHS = getUvPaths();
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// SPECIAL FILES
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Files that should never have FSML order prefixes
|
|
202
|
+
*/
|
|
203
|
+
export const UNORDERED_FILES = new Set([
|
|
204
|
+
'readme.md',
|
|
205
|
+
'readme.qmd',
|
|
206
|
+
'readme',
|
|
207
|
+
'license.md',
|
|
208
|
+
'license.qmd',
|
|
209
|
+
'license',
|
|
210
|
+
'license.txt',
|
|
211
|
+
'changelog.md',
|
|
212
|
+
'changelog.qmd',
|
|
213
|
+
'changelog',
|
|
214
|
+
'contributing.md',
|
|
215
|
+
'contributing.qmd',
|
|
216
|
+
'contributing',
|
|
217
|
+
'mrmd.md',
|
|
218
|
+
'index.md',
|
|
219
|
+
'index.qmd',
|
|
220
|
+
'.gitignore',
|
|
221
|
+
'.gitattributes',
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
// ============================================================================
|
|
225
|
+
// VERSION COMPATIBILITY MATRIX
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Defines which Python package versions are compatible with this electron app.
|
|
228
|
+
// Updated on each release. Uses pip version specifiers.
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Current mrmd-electron version
|
|
232
|
+
*/
|
|
233
|
+
export const APP_VERSION = '0.3.1';
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Python package version requirements for this electron version.
|
|
237
|
+
* These are installed via uv/pip when user sets up a venv.
|
|
238
|
+
*/
|
|
239
|
+
export const PYTHON_DEPS = {
|
|
240
|
+
// Core runtime - required
|
|
241
|
+
'mrmd-python': '>=0.3.7,<0.5',
|
|
242
|
+
|
|
243
|
+
// AI features - required for full experience
|
|
244
|
+
'mrmd-ai': '>=0.1.0,<0.2',
|
|
245
|
+
|
|
246
|
+
// Bash runtime - for ```bash blocks
|
|
247
|
+
'mrmd-bash': '>=0.1.0,<0.2',
|
|
248
|
+
|
|
249
|
+
// PTY runtime - for ```term blocks
|
|
250
|
+
'mrmd-pty': '>=0.1.0,<0.2',
|
|
251
|
+
|
|
252
|
+
// Orchestrator (optional, for advanced multi-runtime setups)
|
|
253
|
+
// 'mrmd': '>=0.2.0,<0.3',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get pip install args for all required Python packages
|
|
258
|
+
* @returns {string[]} Array of package specifiers
|
|
259
|
+
*/
|
|
260
|
+
export function getPythonInstallArgs() {
|
|
261
|
+
return Object.entries(PYTHON_DEPS).map(
|
|
262
|
+
([pkg, version]) => `${pkg}${version}`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ============================================================================
|
|
267
|
+
// UV AUTO-INSTALL
|
|
268
|
+
// ============================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* uv download URLs by platform
|
|
272
|
+
* Updated from: https://github.com/astral-sh/uv/releases
|
|
273
|
+
*/
|
|
274
|
+
export const UV_DOWNLOAD_URLS = {
|
|
275
|
+
'linux-x64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz',
|
|
276
|
+
'linux-arm64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-unknown-linux-gnu.tar.gz',
|
|
277
|
+
'darwin-x64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-apple-darwin.tar.gz',
|
|
278
|
+
'darwin-arm64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz',
|
|
279
|
+
'win32-x64': 'https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-pc-windows-msvc.zip',
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Default uv install location (user-local)
|
|
284
|
+
* Windows: %LOCALAPPDATA%/uv/bin
|
|
285
|
+
* Unix: ~/.local/bin
|
|
286
|
+
*/
|
|
287
|
+
export const UV_INSTALL_DIR = getUvInstallDir();
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Path where uv will be installed
|
|
291
|
+
*/
|
|
292
|
+
export const UV_INSTALL_PATH = path.join(
|
|
293
|
+
UV_INSTALL_DIR,
|
|
294
|
+
isWin ? 'uv.exe' : 'uv'
|
|
295
|
+
);
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssetService - Asset management with deduplication
|
|
3
|
+
*
|
|
4
|
+
* Manages assets in the _assets/ directory.
|
|
5
|
+
* Handles saving with hash-based deduplication, orphan detection, etc.
|
|
6
|
+
*
|
|
7
|
+
* Uses mrmd-project for path computation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Assets } from 'mrmd-project';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import fsPromises from 'fs/promises';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { ASSETS_DIR_NAME, ASSET_MANIFEST_NAME, ASSET_HASH_LENGTH } from '../config.js';
|
|
16
|
+
|
|
17
|
+
// MIME type mapping
|
|
18
|
+
const MIME_TYPES = {
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.webp': 'image/webp',
|
|
24
|
+
'.svg': 'image/svg+xml',
|
|
25
|
+
'.pdf': 'application/pdf',
|
|
26
|
+
'.csv': 'text/csv',
|
|
27
|
+
'.json': 'application/json',
|
|
28
|
+
'.mp4': 'video/mp4',
|
|
29
|
+
'.webm': 'video/webm',
|
|
30
|
+
'.mp3': 'audio/mpeg',
|
|
31
|
+
'.wav': 'audio/wav',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
class AssetService {
|
|
35
|
+
/**
|
|
36
|
+
* @param {FileService} fileService - Reference to FileService for scanning
|
|
37
|
+
*/
|
|
38
|
+
constructor(fileService) {
|
|
39
|
+
this.fileService = fileService;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* List all assets in a project
|
|
44
|
+
*
|
|
45
|
+
* @param {string} projectRoot - Project root path
|
|
46
|
+
* @returns {Promise<AssetInfo[]>}
|
|
47
|
+
*/
|
|
48
|
+
async list(projectRoot) {
|
|
49
|
+
const assetsDir = path.join(projectRoot, ASSETS_DIR_NAME);
|
|
50
|
+
|
|
51
|
+
if (!fs.existsSync(assetsDir)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const manifest = await this.loadManifest(projectRoot);
|
|
56
|
+
const assets = [];
|
|
57
|
+
|
|
58
|
+
const walk = async (dir) => {
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
// Skip hidden files
|
|
68
|
+
if (entry.name.startsWith('.')) continue;
|
|
69
|
+
|
|
70
|
+
const fullPath = path.join(dir, entry.name);
|
|
71
|
+
const relativePath = path.relative(assetsDir, fullPath);
|
|
72
|
+
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
await walk(fullPath);
|
|
75
|
+
} else {
|
|
76
|
+
const stat = await fsPromises.stat(fullPath);
|
|
77
|
+
|
|
78
|
+
// Get hash from manifest or compute it
|
|
79
|
+
let hash = manifest[relativePath]?.hash;
|
|
80
|
+
if (!hash) {
|
|
81
|
+
hash = await this.computeFileHash(fullPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
assets.push({
|
|
85
|
+
path: relativePath,
|
|
86
|
+
fullPath,
|
|
87
|
+
hash,
|
|
88
|
+
size: stat.size,
|
|
89
|
+
mimeType: this.getMimeType(entry.name),
|
|
90
|
+
usedIn: manifest[relativePath]?.usedIn || [],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await walk(assetsDir);
|
|
97
|
+
return assets;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Save an asset (handles deduplication)
|
|
102
|
+
*
|
|
103
|
+
* @param {string} projectRoot - Project root path
|
|
104
|
+
* @param {Buffer} file - File content
|
|
105
|
+
* @param {string} filename - Desired filename
|
|
106
|
+
* @returns {Promise<{ path: string, deduplicated: boolean }>}
|
|
107
|
+
*/
|
|
108
|
+
async save(projectRoot, file, filename) {
|
|
109
|
+
const assetsDir = path.join(projectRoot, ASSETS_DIR_NAME);
|
|
110
|
+
const hash = this.computeHashFromBuffer(file);
|
|
111
|
+
|
|
112
|
+
// Load manifest to check for duplicates
|
|
113
|
+
const manifest = await this.loadManifest(projectRoot);
|
|
114
|
+
|
|
115
|
+
// Check for duplicate by hash
|
|
116
|
+
for (const [assetPath, info] of Object.entries(manifest)) {
|
|
117
|
+
if (info.hash === hash) {
|
|
118
|
+
// Found duplicate
|
|
119
|
+
return { path: assetPath, deduplicated: true };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Also check files not in manifest
|
|
124
|
+
const existingAssets = await this.list(projectRoot);
|
|
125
|
+
for (const asset of existingAssets) {
|
|
126
|
+
if (asset.hash === hash) {
|
|
127
|
+
return { path: asset.path, deduplicated: true };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Save new file
|
|
132
|
+
const assetPath = await this.uniquePath(assetsDir, filename);
|
|
133
|
+
const fullPath = path.join(assetsDir, assetPath);
|
|
134
|
+
|
|
135
|
+
await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
136
|
+
await fsPromises.writeFile(fullPath, file);
|
|
137
|
+
|
|
138
|
+
// Update manifest
|
|
139
|
+
manifest[assetPath] = {
|
|
140
|
+
hash,
|
|
141
|
+
addedAt: new Date().toISOString(),
|
|
142
|
+
usedIn: [],
|
|
143
|
+
};
|
|
144
|
+
await this.saveManifest(projectRoot, manifest);
|
|
145
|
+
|
|
146
|
+
return { path: assetPath, deduplicated: false };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get relative path from a document to an asset
|
|
151
|
+
*
|
|
152
|
+
* @param {string} assetPath - Asset path relative to _assets/
|
|
153
|
+
* @param {string} documentPath - Document path relative to project root
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
getRelativePath(assetPath, documentPath) {
|
|
157
|
+
// Use mrmd-project for computation
|
|
158
|
+
return Assets.computeRelativePath(documentPath, path.join(ASSETS_DIR_NAME, assetPath));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Find orphaned assets (not referenced by any document)
|
|
163
|
+
*
|
|
164
|
+
* @param {string} projectRoot - Project root path
|
|
165
|
+
* @returns {Promise<string[]>} Orphaned asset paths
|
|
166
|
+
*/
|
|
167
|
+
async findOrphans(projectRoot) {
|
|
168
|
+
const assets = await this.list(projectRoot);
|
|
169
|
+
|
|
170
|
+
if (assets.length === 0) return [];
|
|
171
|
+
|
|
172
|
+
// Get all markdown files
|
|
173
|
+
const files = await this.fileService.scan(projectRoot);
|
|
174
|
+
|
|
175
|
+
// Extract all asset references from all documents
|
|
176
|
+
const usedAssets = new Set();
|
|
177
|
+
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
try {
|
|
180
|
+
const fullPath = path.join(projectRoot, file);
|
|
181
|
+
const content = await fsPromises.readFile(fullPath, 'utf8');
|
|
182
|
+
const refs = Assets.extractPaths(content);
|
|
183
|
+
|
|
184
|
+
for (const ref of refs) {
|
|
185
|
+
// Normalize the path to extract just the asset name
|
|
186
|
+
// Paths might be: _assets/img.png, ../_assets/img.png, etc.
|
|
187
|
+
const normalized = this.normalizeAssetRef(ref.path);
|
|
188
|
+
if (normalized) {
|
|
189
|
+
usedAssets.add(normalized);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// Skip files that can't be read
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Find assets not in usedAssets
|
|
198
|
+
return assets
|
|
199
|
+
.filter(a => !usedAssets.has(a.path))
|
|
200
|
+
.map(a => a.path);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Delete an asset
|
|
205
|
+
*
|
|
206
|
+
* @param {string} projectRoot - Project root path
|
|
207
|
+
* @param {string} assetPath - Asset path relative to _assets/
|
|
208
|
+
*/
|
|
209
|
+
async delete(projectRoot, assetPath) {
|
|
210
|
+
const fullPath = path.join(projectRoot, ASSETS_DIR_NAME, assetPath);
|
|
211
|
+
|
|
212
|
+
await fsPromises.unlink(fullPath);
|
|
213
|
+
|
|
214
|
+
// Update manifest
|
|
215
|
+
const manifest = await this.loadManifest(projectRoot);
|
|
216
|
+
delete manifest[assetPath];
|
|
217
|
+
await this.saveManifest(projectRoot, manifest);
|
|
218
|
+
|
|
219
|
+
// Clean up empty directories
|
|
220
|
+
await this.removeEmptyDirs(
|
|
221
|
+
path.dirname(fullPath),
|
|
222
|
+
path.join(projectRoot, ASSETS_DIR_NAME)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Compute hash of a file
|
|
228
|
+
*/
|
|
229
|
+
async computeFileHash(filePath) {
|
|
230
|
+
const content = await fsPromises.readFile(filePath);
|
|
231
|
+
return this.computeHashFromBuffer(content);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Compute hash from buffer
|
|
236
|
+
*/
|
|
237
|
+
computeHashFromBuffer(buffer) {
|
|
238
|
+
return crypto.createHash('sha256').update(buffer).digest('hex').slice(0, ASSET_HASH_LENGTH);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get MIME type from filename
|
|
243
|
+
*/
|
|
244
|
+
getMimeType(filename) {
|
|
245
|
+
const ext = path.extname(filename).toLowerCase();
|
|
246
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Generate a unique path for a new asset.
|
|
251
|
+
* Preserves directory structure (e.g., 'generated/plot.png' stays in 'generated/').
|
|
252
|
+
*/
|
|
253
|
+
async uniquePath(assetsDir, filename) {
|
|
254
|
+
const ext = path.extname(filename);
|
|
255
|
+
const dir = path.dirname(filename);
|
|
256
|
+
const base = path.basename(filename, ext);
|
|
257
|
+
|
|
258
|
+
// Preserve directory structure
|
|
259
|
+
const prefix = dir !== '.' ? dir + '/' : '';
|
|
260
|
+
|
|
261
|
+
let candidate = filename;
|
|
262
|
+
let counter = 1;
|
|
263
|
+
|
|
264
|
+
while (fs.existsSync(path.join(assetsDir, candidate))) {
|
|
265
|
+
candidate = `${prefix}${base}-${counter}${ext}`;
|
|
266
|
+
counter++;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return candidate;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Normalize an asset reference path
|
|
274
|
+
*/
|
|
275
|
+
normalizeAssetRef(refPath) {
|
|
276
|
+
// Handle: _assets/img.png, ../_assets/img.png, ../../_assets/img.png
|
|
277
|
+
const match = refPath.match(/(?:\.\.\/)*_assets\/(.+)/);
|
|
278
|
+
if (match) {
|
|
279
|
+
return match[1];
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Load manifest file
|
|
286
|
+
*/
|
|
287
|
+
async loadManifest(projectRoot) {
|
|
288
|
+
const manifestPath = path.join(projectRoot, ASSETS_DIR_NAME, ASSET_MANIFEST_NAME);
|
|
289
|
+
try {
|
|
290
|
+
const content = await fsPromises.readFile(manifestPath, 'utf8');
|
|
291
|
+
return JSON.parse(content);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
console.warn(`[asset] Could not load manifest: ${e.message}`);
|
|
294
|
+
return {};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Save manifest file
|
|
300
|
+
*/
|
|
301
|
+
async saveManifest(projectRoot, manifest) {
|
|
302
|
+
const assetsDir = path.join(projectRoot, ASSETS_DIR_NAME);
|
|
303
|
+
const manifestPath = path.join(assetsDir, ASSET_MANIFEST_NAME);
|
|
304
|
+
|
|
305
|
+
await fsPromises.mkdir(assetsDir, { recursive: true });
|
|
306
|
+
await fsPromises.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Remove empty directories
|
|
311
|
+
*/
|
|
312
|
+
async removeEmptyDirs(dir, stopAt) {
|
|
313
|
+
while (dir !== stopAt && dir.startsWith(stopAt)) {
|
|
314
|
+
try {
|
|
315
|
+
const entries = await fsPromises.readdir(dir);
|
|
316
|
+
// Don't count manifest file as a real entry
|
|
317
|
+
const realEntries = entries.filter(e => e !== ASSET_MANIFEST_NAME);
|
|
318
|
+
if (realEntries.length === 0) {
|
|
319
|
+
await fsPromises.rmdir(dir);
|
|
320
|
+
dir = path.dirname(dir);
|
|
321
|
+
} else {
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
} catch (e) {
|
|
325
|
+
console.warn(`[asset] Error removing empty directory ${dir}: ${e.message}`);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export default AssetService;
|