@imjp/writenex-astro 0.1.0
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 +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- package/src/types/version.ts +117 -0
|
@@ -0,0 +1,1339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Version history management for content files
|
|
3
|
+
*
|
|
4
|
+
* This module provides functions for creating, reading, and managing
|
|
5
|
+
* version history (shadow copies) of content files. Versions are stored
|
|
6
|
+
* as markdown files in a hidden directory structure with a JSON manifest
|
|
7
|
+
* tracking metadata.
|
|
8
|
+
*
|
|
9
|
+
* ## Storage Structure:
|
|
10
|
+
* ```
|
|
11
|
+
* .writenex/versions/
|
|
12
|
+
* ├── .gitignore # Contains "*" to exclude from Git
|
|
13
|
+
* └── {collection}/
|
|
14
|
+
* └── {contentId}/
|
|
15
|
+
* ├── manifest.json # Version metadata
|
|
16
|
+
* └── {timestamp}.md # Version files
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* @module @writenex/astro/filesystem/versions
|
|
20
|
+
* @see {@link VersionEntry} - Version metadata type
|
|
21
|
+
* @see {@link VersionManifest} - Manifest structure type
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
readFile,
|
|
26
|
+
writeFile,
|
|
27
|
+
mkdir,
|
|
28
|
+
readdir,
|
|
29
|
+
stat,
|
|
30
|
+
unlink,
|
|
31
|
+
} from "node:fs/promises";
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
import { join, basename } from "node:path";
|
|
34
|
+
import matter from "gray-matter";
|
|
35
|
+
import type {
|
|
36
|
+
VersionEntry,
|
|
37
|
+
VersionManifest,
|
|
38
|
+
VersionHistoryConfig,
|
|
39
|
+
Version,
|
|
40
|
+
VersionResult,
|
|
41
|
+
SaveVersionOptions,
|
|
42
|
+
RestoreVersionOptions,
|
|
43
|
+
RestoreResult,
|
|
44
|
+
} from "@/types";
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Constants
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
/** Maximum characters for content preview */
|
|
51
|
+
const PREVIEW_MAX_LENGTH = 100;
|
|
52
|
+
|
|
53
|
+
/** Default gitignore content for version storage */
|
|
54
|
+
const GITIGNORE_CONTENT = "*\n";
|
|
55
|
+
|
|
56
|
+
/** Frontmatter key for storing version label (prefixed to avoid conflicts) */
|
|
57
|
+
const LABEL_FRONTMATTER_KEY = "_writenex_label";
|
|
58
|
+
|
|
59
|
+
/** Lock timeout in milliseconds */
|
|
60
|
+
const LOCK_TIMEOUT_MS = 30000;
|
|
61
|
+
|
|
62
|
+
/** Lock retry interval in milliseconds */
|
|
63
|
+
const LOCK_RETRY_INTERVAL_MS = 50;
|
|
64
|
+
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// Locking Mechanism
|
|
67
|
+
// =============================================================================
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* In-memory lock manager for preventing concurrent manifest operations.
|
|
71
|
+
*
|
|
72
|
+
* Uses a Map to track locks per storage path, with each lock containing
|
|
73
|
+
* a promise that resolves when the lock is released.
|
|
74
|
+
*/
|
|
75
|
+
interface LockEntry {
|
|
76
|
+
/** Promise that resolves when lock is released */
|
|
77
|
+
promise: Promise<void>;
|
|
78
|
+
/** Function to release the lock */
|
|
79
|
+
release: () => void;
|
|
80
|
+
/** Timestamp when lock was acquired */
|
|
81
|
+
acquiredAt: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Map of storage paths to their lock entries */
|
|
85
|
+
const locks = new Map<string, LockEntry>();
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Acquire a lock for a specific storage path.
|
|
89
|
+
*
|
|
90
|
+
* If the path is already locked, waits until the lock is released
|
|
91
|
+
* or timeout is reached.
|
|
92
|
+
*
|
|
93
|
+
* @param storagePath - Path to lock
|
|
94
|
+
* @param timeoutMs - Maximum time to wait for lock (default: 30s)
|
|
95
|
+
* @returns Release function to call when done
|
|
96
|
+
* @throws Error if lock cannot be acquired within timeout
|
|
97
|
+
*/
|
|
98
|
+
async function acquireLock(
|
|
99
|
+
storagePath: string,
|
|
100
|
+
timeoutMs: number = LOCK_TIMEOUT_MS
|
|
101
|
+
): Promise<() => void> {
|
|
102
|
+
const startTime = Date.now();
|
|
103
|
+
|
|
104
|
+
// Wait for existing lock to be released
|
|
105
|
+
while (locks.has(storagePath)) {
|
|
106
|
+
const existingLock = locks.get(storagePath)!;
|
|
107
|
+
|
|
108
|
+
// Check for stale lock (acquired more than timeout ago)
|
|
109
|
+
if (Date.now() - existingLock.acquiredAt > timeoutMs) {
|
|
110
|
+
console.warn(
|
|
111
|
+
`[writenex] Releasing stale lock for ${storagePath} (held for ${Date.now() - existingLock.acquiredAt}ms)`
|
|
112
|
+
);
|
|
113
|
+
existingLock.release();
|
|
114
|
+
locks.delete(storagePath);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check if we've exceeded our timeout
|
|
119
|
+
if (Date.now() - startTime > timeoutMs) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[writenex] Timeout waiting for lock on ${storagePath} after ${timeoutMs}ms`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Wait for lock to be released or retry interval
|
|
126
|
+
await Promise.race([
|
|
127
|
+
existingLock.promise,
|
|
128
|
+
new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_INTERVAL_MS)),
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Create new lock
|
|
133
|
+
let releaseFunc: () => void;
|
|
134
|
+
const lockPromise = new Promise<void>((resolve) => {
|
|
135
|
+
releaseFunc = resolve;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const lockEntry: LockEntry = {
|
|
139
|
+
promise: lockPromise,
|
|
140
|
+
release: releaseFunc!,
|
|
141
|
+
acquiredAt: Date.now(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
locks.set(storagePath, lockEntry);
|
|
145
|
+
|
|
146
|
+
// Return release function that also cleans up the map
|
|
147
|
+
return () => {
|
|
148
|
+
lockEntry.release();
|
|
149
|
+
locks.delete(storagePath);
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Execute a function with an exclusive lock on the storage path.
|
|
155
|
+
*
|
|
156
|
+
* Ensures only one operation can modify the manifest at a time,
|
|
157
|
+
* preventing race conditions during concurrent saves.
|
|
158
|
+
*
|
|
159
|
+
* @param storagePath - Path to lock
|
|
160
|
+
* @param fn - Function to execute while holding the lock
|
|
161
|
+
* @returns Result of the function
|
|
162
|
+
*/
|
|
163
|
+
async function withLock<T>(
|
|
164
|
+
storagePath: string,
|
|
165
|
+
fn: () => Promise<T>
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const release = await acquireLock(storagePath);
|
|
168
|
+
try {
|
|
169
|
+
return await fn();
|
|
170
|
+
} finally {
|
|
171
|
+
release();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =============================================================================
|
|
176
|
+
// Utility Functions
|
|
177
|
+
// =============================================================================
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate a unique version ID based on current timestamp with random suffix.
|
|
181
|
+
*
|
|
182
|
+
* The ID is an ISO-8601 timestamp with colons replaced by hyphens,
|
|
183
|
+
* plus a 4-character random suffix to ensure uniqueness even when
|
|
184
|
+
* multiple versions are created within the same millisecond.
|
|
185
|
+
*
|
|
186
|
+
* Format: YYYY-MM-DDTHH-MM-SS.mmmZ-xxxx
|
|
187
|
+
* Where xxxx is a random alphanumeric suffix.
|
|
188
|
+
*
|
|
189
|
+
* @returns Version ID string (e.g., "2024-12-11T10-30-00.000Z-a1b2")
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* const id = generateVersionId();
|
|
194
|
+
* // Returns: "2024-12-11T10-30-00.000Z-a1b2"
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
export function generateVersionId(): string {
|
|
198
|
+
const timestamp = new Date().toISOString().replace(/:/g, "-");
|
|
199
|
+
const randomSuffix = Math.random().toString(36).substring(2, 6);
|
|
200
|
+
return `${timestamp}-${randomSuffix}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse a version ID back to a Date object.
|
|
205
|
+
*
|
|
206
|
+
* Handles both old format (without suffix) and new format (with random suffix).
|
|
207
|
+
*
|
|
208
|
+
* @param versionId - Version ID string
|
|
209
|
+
* @returns Date object or null if invalid
|
|
210
|
+
*/
|
|
211
|
+
export function parseVersionId(versionId: string): Date | null {
|
|
212
|
+
// Remove random suffix if present (format: ...Z-xxxx)
|
|
213
|
+
const withoutSuffix = versionId.replace(/-[a-z0-9]{4}$/, "");
|
|
214
|
+
|
|
215
|
+
// Convert hyphens back to colons for ISO parsing
|
|
216
|
+
// Format: 2024-12-11T10-30-00.000Z -> 2024-12-11T10:30:00.000Z
|
|
217
|
+
// Note: The dot before milliseconds is preserved by generateVersionId()
|
|
218
|
+
const isoString = withoutSuffix.replace(
|
|
219
|
+
/T(\d{2})-(\d{2})-(\d{2})\.(\d{3})Z/,
|
|
220
|
+
"T$1:$2:$3.$4Z"
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const date = new Date(isoString);
|
|
224
|
+
return isNaN(date.getTime()) ? null : date;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get the storage path for version files of a content item.
|
|
229
|
+
*
|
|
230
|
+
* @param projectRoot - Absolute path to project root
|
|
231
|
+
* @param collection - Collection name
|
|
232
|
+
* @param contentId - Content item ID (slug)
|
|
233
|
+
* @param config - Version history configuration
|
|
234
|
+
* @returns Absolute path to version storage directory
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```typescript
|
|
238
|
+
* const path = getVersionStoragePath(
|
|
239
|
+
* '/project',
|
|
240
|
+
* 'blog',
|
|
241
|
+
* 'my-post',
|
|
242
|
+
* { storagePath: '.writenex/versions' }
|
|
243
|
+
* );
|
|
244
|
+
* // Returns: "/project/.writenex/versions/blog/my-post"
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function getVersionStoragePath(
|
|
248
|
+
projectRoot: string,
|
|
249
|
+
collection: string,
|
|
250
|
+
contentId: string,
|
|
251
|
+
config: Required<VersionHistoryConfig>
|
|
252
|
+
): string {
|
|
253
|
+
return join(projectRoot, config.storagePath, collection, contentId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get the path to a specific version file.
|
|
258
|
+
*
|
|
259
|
+
* @param storagePath - Version storage directory path
|
|
260
|
+
* @param versionId - Version ID
|
|
261
|
+
* @returns Absolute path to version file
|
|
262
|
+
*/
|
|
263
|
+
export function getVersionFilePath(
|
|
264
|
+
storagePath: string,
|
|
265
|
+
versionId: string
|
|
266
|
+
): string {
|
|
267
|
+
return join(storagePath, `${versionId}.md`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Get the path to the manifest file for a content item.
|
|
272
|
+
*
|
|
273
|
+
* @param storagePath - Version storage directory path
|
|
274
|
+
* @returns Absolute path to manifest file
|
|
275
|
+
*/
|
|
276
|
+
export function getManifestPath(storagePath: string): string {
|
|
277
|
+
return join(storagePath, "manifest.json");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Generate a preview string from content.
|
|
282
|
+
*
|
|
283
|
+
* Extracts the first 100 characters of the content body,
|
|
284
|
+
* stripping frontmatter if present.
|
|
285
|
+
*
|
|
286
|
+
* @param content - Full markdown content
|
|
287
|
+
* @returns Preview string (max 100 characters)
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```typescript
|
|
291
|
+
* const preview = generatePreview("---\ntitle: Test\n---\n\n# Hello World\n\nThis is content.");
|
|
292
|
+
* // Returns: "# Hello World\n\nThis is content."
|
|
293
|
+
* ```
|
|
294
|
+
*/
|
|
295
|
+
export function generatePreview(content: string): string {
|
|
296
|
+
// Parse frontmatter to get body only
|
|
297
|
+
try {
|
|
298
|
+
const { content: body } = matter(content);
|
|
299
|
+
const trimmed = body.trim();
|
|
300
|
+
|
|
301
|
+
if (trimmed.length <= PREVIEW_MAX_LENGTH) {
|
|
302
|
+
return trimmed;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return trimmed.substring(0, PREVIEW_MAX_LENGTH);
|
|
306
|
+
} catch {
|
|
307
|
+
// If parsing fails, use raw content
|
|
308
|
+
const trimmed = content.trim();
|
|
309
|
+
return trimmed.length <= PREVIEW_MAX_LENGTH
|
|
310
|
+
? trimmed
|
|
311
|
+
: trimmed.substring(0, PREVIEW_MAX_LENGTH);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Extract label from version file content.
|
|
317
|
+
*
|
|
318
|
+
* Reads the special _writenex_label frontmatter field that stores
|
|
319
|
+
* the version label for recovery purposes.
|
|
320
|
+
*
|
|
321
|
+
* @param content - Full markdown content of version file
|
|
322
|
+
* @returns Label string or undefined if not present
|
|
323
|
+
*/
|
|
324
|
+
export function extractLabelFromContent(content: string): string | undefined {
|
|
325
|
+
try {
|
|
326
|
+
const { data } = matter(content);
|
|
327
|
+
const label = data[LABEL_FRONTMATTER_KEY];
|
|
328
|
+
return typeof label === "string" ? label : undefined;
|
|
329
|
+
} catch {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Inject label into content as frontmatter for persistence.
|
|
336
|
+
*
|
|
337
|
+
* Adds the _writenex_label field to frontmatter so the label
|
|
338
|
+
* can be recovered if the manifest is lost or corrupted.
|
|
339
|
+
*
|
|
340
|
+
* @param content - Original markdown content
|
|
341
|
+
* @param label - Label to inject
|
|
342
|
+
* @returns Content with label injected in frontmatter
|
|
343
|
+
*/
|
|
344
|
+
export function injectLabelIntoContent(content: string, label: string): string {
|
|
345
|
+
try {
|
|
346
|
+
const { data, content: body } = matter(content);
|
|
347
|
+
|
|
348
|
+
// Add label to frontmatter
|
|
349
|
+
const newData = { ...data, [LABEL_FRONTMATTER_KEY]: label };
|
|
350
|
+
|
|
351
|
+
// Reconstruct the file with updated frontmatter
|
|
352
|
+
return matter.stringify(body, newData);
|
|
353
|
+
} catch {
|
|
354
|
+
// If parsing fails, prepend frontmatter with just the label
|
|
355
|
+
return `---\n${LABEL_FRONTMATTER_KEY}: "${label}"\n---\n\n${content}`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Remove the internal label field from content for user-facing operations.
|
|
361
|
+
*
|
|
362
|
+
* Strips the _writenex_label field from frontmatter when returning
|
|
363
|
+
* content to users (e.g., during restore).
|
|
364
|
+
*
|
|
365
|
+
* @param content - Content that may contain internal label field
|
|
366
|
+
* @returns Content with internal label field removed
|
|
367
|
+
*/
|
|
368
|
+
export function stripLabelFromContent(content: string): string {
|
|
369
|
+
try {
|
|
370
|
+
const { data, content: body } = matter(content);
|
|
371
|
+
|
|
372
|
+
// Remove the internal label field
|
|
373
|
+
if (LABEL_FRONTMATTER_KEY in data) {
|
|
374
|
+
const { [LABEL_FRONTMATTER_KEY]: _, ...cleanData } = data;
|
|
375
|
+
|
|
376
|
+
// If no other frontmatter, return just the body
|
|
377
|
+
if (Object.keys(cleanData).length === 0) {
|
|
378
|
+
return body.startsWith("\n") ? body.slice(1) : body;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return matter.stringify(body, cleanData);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return content;
|
|
385
|
+
} catch {
|
|
386
|
+
return content;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Ensure the .gitignore file exists in the version storage root.
|
|
392
|
+
*
|
|
393
|
+
* Creates a .gitignore file with "*" pattern to exclude all version
|
|
394
|
+
* files from Git tracking.
|
|
395
|
+
*
|
|
396
|
+
* @param projectRoot - Absolute path to project root
|
|
397
|
+
* @param config - Version history configuration
|
|
398
|
+
*
|
|
399
|
+
* @example
|
|
400
|
+
* ```typescript
|
|
401
|
+
* await ensureGitignore('/project', { storagePath: '.writenex/versions' });
|
|
402
|
+
* // Creates: /project/.writenex/versions/.gitignore with content "*"
|
|
403
|
+
* ```
|
|
404
|
+
*/
|
|
405
|
+
export async function ensureGitignore(
|
|
406
|
+
projectRoot: string,
|
|
407
|
+
config: Required<VersionHistoryConfig>
|
|
408
|
+
): Promise<void> {
|
|
409
|
+
const storageRoot = join(projectRoot, config.storagePath);
|
|
410
|
+
const gitignorePath = join(storageRoot, ".gitignore");
|
|
411
|
+
|
|
412
|
+
// Ensure storage directory exists
|
|
413
|
+
if (!existsSync(storageRoot)) {
|
|
414
|
+
await mkdir(storageRoot, { recursive: true });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Create .gitignore if it doesn't exist
|
|
418
|
+
if (!existsSync(gitignorePath)) {
|
|
419
|
+
await writeFile(gitignorePath, GITIGNORE_CONTENT, "utf-8");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Ensure the version storage directory exists for a content item.
|
|
425
|
+
*
|
|
426
|
+
* @param storagePath - Version storage directory path
|
|
427
|
+
*/
|
|
428
|
+
export async function ensureStorageDirectory(
|
|
429
|
+
storagePath: string
|
|
430
|
+
): Promise<void> {
|
|
431
|
+
if (!existsSync(storagePath)) {
|
|
432
|
+
await mkdir(storagePath, { recursive: true });
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// =============================================================================
|
|
437
|
+
// Manifest Operations
|
|
438
|
+
// =============================================================================
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Read the version manifest for a content item.
|
|
442
|
+
*
|
|
443
|
+
* @param storagePath - Version storage directory path
|
|
444
|
+
* @returns Version manifest or null if not found/corrupted
|
|
445
|
+
*
|
|
446
|
+
* @example
|
|
447
|
+
* ```typescript
|
|
448
|
+
* const manifest = await readManifest('/project/.writenex/versions/blog/my-post');
|
|
449
|
+
* if (manifest) {
|
|
450
|
+
* console.log(`Found ${manifest.versions.length} versions`);
|
|
451
|
+
* }
|
|
452
|
+
* ```
|
|
453
|
+
*/
|
|
454
|
+
export async function readManifest(
|
|
455
|
+
storagePath: string
|
|
456
|
+
): Promise<VersionManifest | null> {
|
|
457
|
+
const manifestPath = getManifestPath(storagePath);
|
|
458
|
+
|
|
459
|
+
if (!existsSync(manifestPath)) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const content = await readFile(manifestPath, "utf-8");
|
|
465
|
+
const data = JSON.parse(content) as VersionManifest;
|
|
466
|
+
|
|
467
|
+
// Validate required fields
|
|
468
|
+
if (!data.contentId || !data.collection || !Array.isArray(data.versions)) {
|
|
469
|
+
console.warn(`[writenex] Corrupted manifest at ${manifestPath}`);
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return data;
|
|
474
|
+
} catch (error) {
|
|
475
|
+
console.warn(
|
|
476
|
+
`[writenex] Failed to read manifest at ${manifestPath}:`,
|
|
477
|
+
error
|
|
478
|
+
);
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Write the version manifest for a content item.
|
|
485
|
+
*
|
|
486
|
+
* @param storagePath - Version storage directory path
|
|
487
|
+
* @param manifest - Version manifest to write
|
|
488
|
+
*
|
|
489
|
+
* @example
|
|
490
|
+
* ```typescript
|
|
491
|
+
* await writeManifest('/project/.writenex/versions/blog/my-post', {
|
|
492
|
+
* contentId: 'my-post',
|
|
493
|
+
* collection: 'blog',
|
|
494
|
+
* versions: [],
|
|
495
|
+
* updatedAt: new Date().toISOString(),
|
|
496
|
+
* });
|
|
497
|
+
* ```
|
|
498
|
+
*/
|
|
499
|
+
export async function writeManifest(
|
|
500
|
+
storagePath: string,
|
|
501
|
+
manifest: VersionManifest
|
|
502
|
+
): Promise<void> {
|
|
503
|
+
await ensureStorageDirectory(storagePath);
|
|
504
|
+
|
|
505
|
+
const manifestPath = getManifestPath(storagePath);
|
|
506
|
+
const content = JSON.stringify(manifest, null, 2);
|
|
507
|
+
|
|
508
|
+
await writeFile(manifestPath, content, "utf-8");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Create an empty manifest for a content item.
|
|
513
|
+
*
|
|
514
|
+
* @param collection - Collection name
|
|
515
|
+
* @param contentId - Content item ID
|
|
516
|
+
* @returns New empty manifest
|
|
517
|
+
*/
|
|
518
|
+
export function createEmptyManifest(
|
|
519
|
+
collection: string,
|
|
520
|
+
contentId: string
|
|
521
|
+
): VersionManifest {
|
|
522
|
+
return {
|
|
523
|
+
contentId,
|
|
524
|
+
collection,
|
|
525
|
+
versions: [],
|
|
526
|
+
updatedAt: new Date().toISOString(),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Recover manifest by scanning version files in the storage directory.
|
|
532
|
+
*
|
|
533
|
+
* This function rebuilds the manifest from existing version files
|
|
534
|
+
* when the manifest is corrupted or missing.
|
|
535
|
+
*
|
|
536
|
+
* @param storagePath - Version storage directory path
|
|
537
|
+
* @param collection - Collection name
|
|
538
|
+
* @param contentId - Content item ID
|
|
539
|
+
* @returns Recovered manifest
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* ```typescript
|
|
543
|
+
* const manifest = await recoverManifest(
|
|
544
|
+
* '/project/.writenex/versions/blog/my-post',
|
|
545
|
+
* 'blog',
|
|
546
|
+
* 'my-post'
|
|
547
|
+
* );
|
|
548
|
+
* ```
|
|
549
|
+
*/
|
|
550
|
+
export async function recoverManifest(
|
|
551
|
+
storagePath: string,
|
|
552
|
+
collection: string,
|
|
553
|
+
contentId: string
|
|
554
|
+
): Promise<VersionManifest> {
|
|
555
|
+
const manifest = createEmptyManifest(collection, contentId);
|
|
556
|
+
|
|
557
|
+
if (!existsSync(storagePath)) {
|
|
558
|
+
return manifest;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const files = await readdir(storagePath);
|
|
563
|
+
const versionFiles = files.filter(
|
|
564
|
+
(f) => f.endsWith(".md") && f !== "manifest.json"
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
for (const file of versionFiles) {
|
|
568
|
+
const versionId = basename(file, ".md");
|
|
569
|
+
const filePath = join(storagePath, file);
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
const content = await readFile(filePath, "utf-8");
|
|
573
|
+
const stats = await stat(filePath);
|
|
574
|
+
const timestamp = parseVersionId(versionId);
|
|
575
|
+
|
|
576
|
+
if (timestamp) {
|
|
577
|
+
// Extract label from content if present (for recovery)
|
|
578
|
+
const label = extractLabelFromContent(content);
|
|
579
|
+
|
|
580
|
+
const entry: VersionEntry = {
|
|
581
|
+
id: versionId,
|
|
582
|
+
timestamp: timestamp.toISOString(),
|
|
583
|
+
preview: generatePreview(content),
|
|
584
|
+
size: stats.size,
|
|
585
|
+
...(label ? { label } : {}),
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
manifest.versions.push(entry);
|
|
589
|
+
}
|
|
590
|
+
} catch {
|
|
591
|
+
// Skip files that can't be read
|
|
592
|
+
console.warn(`[writenex] Skipping unreadable version file: ${file}`);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Sort by timestamp descending (newest first)
|
|
597
|
+
manifest.versions.sort(
|
|
598
|
+
(a, b) =>
|
|
599
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
manifest.updatedAt = new Date().toISOString();
|
|
603
|
+
|
|
604
|
+
// Write recovered manifest
|
|
605
|
+
await writeManifest(storagePath, manifest);
|
|
606
|
+
|
|
607
|
+
return manifest;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
console.warn(`[writenex] Failed to recover manifest:`, error);
|
|
610
|
+
return manifest;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Get or recover manifest for a content item.
|
|
616
|
+
*
|
|
617
|
+
* Attempts to read existing manifest, falls back to recovery if corrupted.
|
|
618
|
+
*
|
|
619
|
+
* @param storagePath - Version storage directory path
|
|
620
|
+
* @param collection - Collection name
|
|
621
|
+
* @param contentId - Content item ID
|
|
622
|
+
* @returns Version manifest
|
|
623
|
+
*/
|
|
624
|
+
export async function getOrRecoverManifest(
|
|
625
|
+
storagePath: string,
|
|
626
|
+
collection: string,
|
|
627
|
+
contentId: string
|
|
628
|
+
): Promise<VersionManifest> {
|
|
629
|
+
const manifest = await readManifest(storagePath);
|
|
630
|
+
|
|
631
|
+
if (manifest) {
|
|
632
|
+
return manifest;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Try to recover from version files
|
|
636
|
+
return recoverManifest(storagePath, collection, contentId);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// =============================================================================
|
|
640
|
+
// Version CRUD Operations
|
|
641
|
+
// =============================================================================
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Save a version snapshot of content.
|
|
645
|
+
*
|
|
646
|
+
* Creates a new version file with the provided content and updates
|
|
647
|
+
* the manifest. Automatically prunes old versions if the limit is exceeded.
|
|
648
|
+
*
|
|
649
|
+
* @param projectRoot - Absolute path to project root
|
|
650
|
+
* @param collection - Collection name
|
|
651
|
+
* @param contentId - Content item ID (slug)
|
|
652
|
+
* @param content - Full markdown content to save
|
|
653
|
+
* @param config - Version history configuration
|
|
654
|
+
* @param options - Save options
|
|
655
|
+
* @returns Result of the save operation
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* ```typescript
|
|
659
|
+
* const result = await saveVersion(
|
|
660
|
+
* '/project',
|
|
661
|
+
* 'blog',
|
|
662
|
+
* 'my-post',
|
|
663
|
+
* '---\ntitle: My Post\n---\n\nContent here...',
|
|
664
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
665
|
+
* );
|
|
666
|
+
*
|
|
667
|
+
* if (result.success) {
|
|
668
|
+
* console.log(`Created version: ${result.version?.id}`);
|
|
669
|
+
* }
|
|
670
|
+
* ```
|
|
671
|
+
*/
|
|
672
|
+
export async function saveVersion(
|
|
673
|
+
projectRoot: string,
|
|
674
|
+
collection: string,
|
|
675
|
+
contentId: string,
|
|
676
|
+
content: string,
|
|
677
|
+
config: Required<VersionHistoryConfig>,
|
|
678
|
+
options: SaveVersionOptions = {}
|
|
679
|
+
): Promise<VersionResult> {
|
|
680
|
+
const { label, skipIfIdentical = false } = options;
|
|
681
|
+
|
|
682
|
+
// Check if version history is enabled
|
|
683
|
+
if (!config.enabled) {
|
|
684
|
+
return { success: true };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Get storage path for this content item
|
|
688
|
+
const storagePath = getVersionStoragePath(
|
|
689
|
+
projectRoot,
|
|
690
|
+
collection,
|
|
691
|
+
contentId,
|
|
692
|
+
config
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// Use lock to prevent concurrent manifest modifications
|
|
696
|
+
return withLock(storagePath, async () => {
|
|
697
|
+
try {
|
|
698
|
+
// Ensure gitignore exists in storage root
|
|
699
|
+
await ensureGitignore(projectRoot, config);
|
|
700
|
+
|
|
701
|
+
await ensureStorageDirectory(storagePath);
|
|
702
|
+
|
|
703
|
+
// Get or create manifest
|
|
704
|
+
const manifest = await getOrRecoverManifest(
|
|
705
|
+
storagePath,
|
|
706
|
+
collection,
|
|
707
|
+
contentId
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
// Check if content is identical to last version (if skipIfIdentical is true)
|
|
711
|
+
if (skipIfIdentical && manifest.versions.length > 0) {
|
|
712
|
+
const lastVersion = manifest.versions[0];
|
|
713
|
+
if (lastVersion) {
|
|
714
|
+
const lastVersionPath = getVersionFilePath(
|
|
715
|
+
storagePath,
|
|
716
|
+
lastVersion.id
|
|
717
|
+
);
|
|
718
|
+
if (existsSync(lastVersionPath)) {
|
|
719
|
+
try {
|
|
720
|
+
const lastContent = await readFile(lastVersionPath, "utf-8");
|
|
721
|
+
if (lastContent === content) {
|
|
722
|
+
return { success: true, version: lastVersion };
|
|
723
|
+
}
|
|
724
|
+
} catch {
|
|
725
|
+
// If we can't read the last version, proceed with saving
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Generate version ID and create version file
|
|
732
|
+
// Use the same timestamp for both id and timestamp to ensure consistency
|
|
733
|
+
const now = new Date();
|
|
734
|
+
const versionId = now.toISOString().replace(/:/g, "-");
|
|
735
|
+
const versionPath = getVersionFilePath(storagePath, versionId);
|
|
736
|
+
|
|
737
|
+
// If label is provided, inject it into content for recovery purposes
|
|
738
|
+
const contentToSave = label
|
|
739
|
+
? injectLabelIntoContent(content, label)
|
|
740
|
+
: content;
|
|
741
|
+
|
|
742
|
+
// Write version file
|
|
743
|
+
await writeFile(versionPath, contentToSave, "utf-8");
|
|
744
|
+
|
|
745
|
+
// Get file stats for size
|
|
746
|
+
const stats = await stat(versionPath);
|
|
747
|
+
|
|
748
|
+
// Create version entry
|
|
749
|
+
const entry: VersionEntry = {
|
|
750
|
+
id: versionId,
|
|
751
|
+
timestamp: now.toISOString(),
|
|
752
|
+
preview: generatePreview(content),
|
|
753
|
+
size: stats.size,
|
|
754
|
+
...(label ? { label } : {}),
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Add to manifest (newest first)
|
|
758
|
+
manifest.versions.unshift(entry);
|
|
759
|
+
manifest.updatedAt = new Date().toISOString();
|
|
760
|
+
|
|
761
|
+
// Write updated manifest
|
|
762
|
+
await writeManifest(storagePath, manifest);
|
|
763
|
+
|
|
764
|
+
// Prune old versions (inside lock to prevent race)
|
|
765
|
+
await pruneVersionsInternal(storagePath, config);
|
|
766
|
+
|
|
767
|
+
return { success: true, version: entry };
|
|
768
|
+
} catch (error) {
|
|
769
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
770
|
+
console.error(`[writenex] Failed to save version:`, error);
|
|
771
|
+
return { success: false, error: `Failed to save version: ${message}` };
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Get all versions for a content item.
|
|
778
|
+
*
|
|
779
|
+
* Returns versions sorted by timestamp in descending order (newest first).
|
|
780
|
+
* Handles missing or corrupted manifests gracefully.
|
|
781
|
+
*
|
|
782
|
+
* @param projectRoot - Absolute path to project root
|
|
783
|
+
* @param collection - Collection name
|
|
784
|
+
* @param contentId - Content item ID (slug)
|
|
785
|
+
* @param config - Version history configuration
|
|
786
|
+
* @returns Array of version entries
|
|
787
|
+
*
|
|
788
|
+
* @example
|
|
789
|
+
* ```typescript
|
|
790
|
+
* const versions = await getVersions(
|
|
791
|
+
* '/project',
|
|
792
|
+
* 'blog',
|
|
793
|
+
* 'my-post',
|
|
794
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
795
|
+
* );
|
|
796
|
+
*
|
|
797
|
+
* console.log(`Found ${versions.length} versions`);
|
|
798
|
+
* ```
|
|
799
|
+
*/
|
|
800
|
+
export async function getVersions(
|
|
801
|
+
projectRoot: string,
|
|
802
|
+
collection: string,
|
|
803
|
+
contentId: string,
|
|
804
|
+
config: Required<VersionHistoryConfig>
|
|
805
|
+
): Promise<VersionEntry[]> {
|
|
806
|
+
// Check if version history is enabled
|
|
807
|
+
if (!config.enabled) {
|
|
808
|
+
return [];
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
try {
|
|
812
|
+
const storagePath = getVersionStoragePath(
|
|
813
|
+
projectRoot,
|
|
814
|
+
collection,
|
|
815
|
+
contentId,
|
|
816
|
+
config
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
// Check if storage directory exists
|
|
820
|
+
if (!existsSync(storagePath)) {
|
|
821
|
+
return [];
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Get or recover manifest
|
|
825
|
+
const manifest = await getOrRecoverManifest(
|
|
826
|
+
storagePath,
|
|
827
|
+
collection,
|
|
828
|
+
contentId
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
// Return versions sorted by timestamp descending (newest first)
|
|
832
|
+
return [...manifest.versions].sort(
|
|
833
|
+
(a, b) =>
|
|
834
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
835
|
+
);
|
|
836
|
+
} catch (error) {
|
|
837
|
+
console.warn(`[writenex] Failed to get versions:`, error);
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Get a specific version with full content.
|
|
844
|
+
*
|
|
845
|
+
* Reads the version file and parses it to return structured data
|
|
846
|
+
* with frontmatter and body separated.
|
|
847
|
+
*
|
|
848
|
+
* @param projectRoot - Absolute path to project root
|
|
849
|
+
* @param collection - Collection name
|
|
850
|
+
* @param contentId - Content item ID (slug)
|
|
851
|
+
* @param versionId - Version ID to retrieve
|
|
852
|
+
* @param config - Version history configuration
|
|
853
|
+
* @returns Full version data or null if not found
|
|
854
|
+
*
|
|
855
|
+
* @example
|
|
856
|
+
* ```typescript
|
|
857
|
+
* const version = await getVersion(
|
|
858
|
+
* '/project',
|
|
859
|
+
* 'blog',
|
|
860
|
+
* 'my-post',
|
|
861
|
+
* '2024-12-11T10-30-00-000Z',
|
|
862
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
863
|
+
* );
|
|
864
|
+
*
|
|
865
|
+
* if (version) {
|
|
866
|
+
* console.log(`Title: ${version.frontmatter.title}`);
|
|
867
|
+
* console.log(`Body: ${version.body}`);
|
|
868
|
+
* }
|
|
869
|
+
* ```
|
|
870
|
+
*/
|
|
871
|
+
export async function getVersion(
|
|
872
|
+
projectRoot: string,
|
|
873
|
+
collection: string,
|
|
874
|
+
contentId: string,
|
|
875
|
+
versionId: string,
|
|
876
|
+
config: Required<VersionHistoryConfig>
|
|
877
|
+
): Promise<Version | null> {
|
|
878
|
+
// Check if version history is enabled
|
|
879
|
+
if (!config.enabled) {
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
const storagePath = getVersionStoragePath(
|
|
885
|
+
projectRoot,
|
|
886
|
+
collection,
|
|
887
|
+
contentId,
|
|
888
|
+
config
|
|
889
|
+
);
|
|
890
|
+
const versionPath = getVersionFilePath(storagePath, versionId);
|
|
891
|
+
|
|
892
|
+
// Check if version file exists
|
|
893
|
+
if (!existsSync(versionPath)) {
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Read version file
|
|
898
|
+
const rawContent = await readFile(versionPath, "utf-8");
|
|
899
|
+
const stats = await stat(versionPath);
|
|
900
|
+
|
|
901
|
+
// Extract label from content (stored for recovery)
|
|
902
|
+
const labelFromContent = extractLabelFromContent(rawContent);
|
|
903
|
+
|
|
904
|
+
// Strip internal label field before returning to user
|
|
905
|
+
const content = stripLabelFromContent(rawContent);
|
|
906
|
+
|
|
907
|
+
// Parse frontmatter from cleaned content
|
|
908
|
+
const { data: frontmatter, content: body } = matter(content);
|
|
909
|
+
|
|
910
|
+
// Get timestamp from version ID
|
|
911
|
+
const timestamp = parseVersionId(versionId);
|
|
912
|
+
if (!timestamp) {
|
|
913
|
+
return null;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Get label from manifest first, fall back to content-embedded label
|
|
917
|
+
const manifest = await readManifest(storagePath);
|
|
918
|
+
const manifestEntry = manifest?.versions.find((v) => v.id === versionId);
|
|
919
|
+
const label = manifestEntry?.label ?? labelFromContent;
|
|
920
|
+
|
|
921
|
+
return {
|
|
922
|
+
id: versionId,
|
|
923
|
+
timestamp: timestamp.toISOString(),
|
|
924
|
+
preview: generatePreview(content),
|
|
925
|
+
size: stats.size,
|
|
926
|
+
content,
|
|
927
|
+
frontmatter,
|
|
928
|
+
body: body.trim(),
|
|
929
|
+
...(label ? { label } : {}),
|
|
930
|
+
};
|
|
931
|
+
} catch (error) {
|
|
932
|
+
console.warn(`[writenex] Failed to get version ${versionId}:`, error);
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Delete a specific version.
|
|
939
|
+
*
|
|
940
|
+
* Removes the version file from the filesystem and updates the manifest.
|
|
941
|
+
*
|
|
942
|
+
* @param projectRoot - Absolute path to project root
|
|
943
|
+
* @param collection - Collection name
|
|
944
|
+
* @param contentId - Content item ID (slug)
|
|
945
|
+
* @param versionId - Version ID to delete
|
|
946
|
+
* @param config - Version history configuration
|
|
947
|
+
* @returns Result of the delete operation
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* ```typescript
|
|
951
|
+
* const result = await deleteVersion(
|
|
952
|
+
* '/project',
|
|
953
|
+
* 'blog',
|
|
954
|
+
* 'my-post',
|
|
955
|
+
* '2024-12-11T10-30-00-000Z',
|
|
956
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
957
|
+
* );
|
|
958
|
+
*
|
|
959
|
+
* if (result.success) {
|
|
960
|
+
* console.log('Version deleted');
|
|
961
|
+
* }
|
|
962
|
+
* ```
|
|
963
|
+
*/
|
|
964
|
+
export async function deleteVersion(
|
|
965
|
+
projectRoot: string,
|
|
966
|
+
collection: string,
|
|
967
|
+
contentId: string,
|
|
968
|
+
versionId: string,
|
|
969
|
+
config: Required<VersionHistoryConfig>
|
|
970
|
+
): Promise<VersionResult> {
|
|
971
|
+
const storagePath = getVersionStoragePath(
|
|
972
|
+
projectRoot,
|
|
973
|
+
collection,
|
|
974
|
+
contentId,
|
|
975
|
+
config
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
// Use lock to prevent concurrent manifest modifications
|
|
979
|
+
return withLock(storagePath, async () => {
|
|
980
|
+
try {
|
|
981
|
+
const versionPath = getVersionFilePath(storagePath, versionId);
|
|
982
|
+
|
|
983
|
+
// Check if version file exists
|
|
984
|
+
if (!existsSync(versionPath)) {
|
|
985
|
+
return { success: false, error: `Version not found: ${versionId}` };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Get manifest
|
|
989
|
+
const manifest = await getOrRecoverManifest(
|
|
990
|
+
storagePath,
|
|
991
|
+
collection,
|
|
992
|
+
contentId
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
// Find version entry
|
|
996
|
+
const entryIndex = manifest.versions.findIndex((v) => v.id === versionId);
|
|
997
|
+
const entry = entryIndex >= 0 ? manifest.versions[entryIndex] : undefined;
|
|
998
|
+
|
|
999
|
+
// Delete version file
|
|
1000
|
+
await unlink(versionPath);
|
|
1001
|
+
|
|
1002
|
+
// Update manifest
|
|
1003
|
+
if (entryIndex >= 0) {
|
|
1004
|
+
manifest.versions.splice(entryIndex, 1);
|
|
1005
|
+
manifest.updatedAt = new Date().toISOString();
|
|
1006
|
+
await writeManifest(storagePath, manifest);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return { success: true, version: entry };
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1012
|
+
console.error(`[writenex] Failed to delete version:`, error);
|
|
1013
|
+
return { success: false, error: `Failed to delete version: ${message}` };
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Clear all versions for a content item.
|
|
1020
|
+
*
|
|
1021
|
+
* Deletes all version files and resets the manifest to empty state.
|
|
1022
|
+
*
|
|
1023
|
+
* @param projectRoot - Absolute path to project root
|
|
1024
|
+
* @param collection - Collection name
|
|
1025
|
+
* @param contentId - Content item ID (slug)
|
|
1026
|
+
* @param config - Version history configuration
|
|
1027
|
+
* @returns Result of the clear operation
|
|
1028
|
+
*
|
|
1029
|
+
* @example
|
|
1030
|
+
* ```typescript
|
|
1031
|
+
* const result = await clearVersions(
|
|
1032
|
+
* '/project',
|
|
1033
|
+
* 'blog',
|
|
1034
|
+
* 'my-post',
|
|
1035
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
1036
|
+
* );
|
|
1037
|
+
*
|
|
1038
|
+
* if (result.success) {
|
|
1039
|
+
* console.log('All versions cleared');
|
|
1040
|
+
* }
|
|
1041
|
+
* ```
|
|
1042
|
+
*/
|
|
1043
|
+
export async function clearVersions(
|
|
1044
|
+
projectRoot: string,
|
|
1045
|
+
collection: string,
|
|
1046
|
+
contentId: string,
|
|
1047
|
+
config: Required<VersionHistoryConfig>
|
|
1048
|
+
): Promise<VersionResult> {
|
|
1049
|
+
const storagePath = getVersionStoragePath(
|
|
1050
|
+
projectRoot,
|
|
1051
|
+
collection,
|
|
1052
|
+
contentId,
|
|
1053
|
+
config
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
// Check if storage directory exists (no lock needed for this check)
|
|
1057
|
+
if (!existsSync(storagePath)) {
|
|
1058
|
+
return { success: true };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Use lock to prevent concurrent manifest modifications
|
|
1062
|
+
return withLock(storagePath, async () => {
|
|
1063
|
+
try {
|
|
1064
|
+
// Get all version files
|
|
1065
|
+
const files = await readdir(storagePath);
|
|
1066
|
+
const versionFiles = files.filter(
|
|
1067
|
+
(f) => f.endsWith(".md") && f !== "manifest.json"
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
// Delete all version files
|
|
1071
|
+
for (const file of versionFiles) {
|
|
1072
|
+
const filePath = join(storagePath, file);
|
|
1073
|
+
try {
|
|
1074
|
+
await unlink(filePath);
|
|
1075
|
+
} catch {
|
|
1076
|
+
// Ignore errors for individual files
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Reset manifest
|
|
1081
|
+
const manifest = createEmptyManifest(collection, contentId);
|
|
1082
|
+
await writeManifest(storagePath, manifest);
|
|
1083
|
+
|
|
1084
|
+
return { success: true };
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
+
console.error(`[writenex] Failed to clear versions:`, error);
|
|
1088
|
+
return { success: false, error: `Failed to clear versions: ${message}` };
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Prune old versions to maintain the maximum limit.
|
|
1095
|
+
*
|
|
1096
|
+
* Deletes the oldest unlabeled versions when the count exceeds maxVersions.
|
|
1097
|
+
* Labeled versions are preserved regardless of count.
|
|
1098
|
+
*
|
|
1099
|
+
* @param projectRoot - Absolute path to project root
|
|
1100
|
+
* @param collection - Collection name
|
|
1101
|
+
* @param contentId - Content item ID (slug)
|
|
1102
|
+
* @param config - Version history configuration
|
|
1103
|
+
* @returns Result of the prune operation
|
|
1104
|
+
*
|
|
1105
|
+
* @example
|
|
1106
|
+
* ```typescript
|
|
1107
|
+
* const result = await pruneVersions(
|
|
1108
|
+
* '/project',
|
|
1109
|
+
* 'blog',
|
|
1110
|
+
* 'my-post',
|
|
1111
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
1112
|
+
* );
|
|
1113
|
+
* ```
|
|
1114
|
+
*/
|
|
1115
|
+
/**
|
|
1116
|
+
* Internal prune function that assumes lock is already held.
|
|
1117
|
+
*
|
|
1118
|
+
* @param storagePath - Version storage directory path
|
|
1119
|
+
* @param config - Version history configuration
|
|
1120
|
+
* @returns Result of the prune operation
|
|
1121
|
+
*/
|
|
1122
|
+
async function pruneVersionsInternal(
|
|
1123
|
+
storagePath: string,
|
|
1124
|
+
config: Required<VersionHistoryConfig>
|
|
1125
|
+
): Promise<VersionResult> {
|
|
1126
|
+
try {
|
|
1127
|
+
// Check if storage directory exists
|
|
1128
|
+
if (!existsSync(storagePath)) {
|
|
1129
|
+
return { success: true };
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Get manifest (read fresh to ensure we have latest data)
|
|
1133
|
+
const manifest = await readManifest(storagePath);
|
|
1134
|
+
if (!manifest) {
|
|
1135
|
+
return { success: true };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Separate labeled and unlabeled versions
|
|
1139
|
+
const labeledVersions = manifest.versions.filter((v) => v.label);
|
|
1140
|
+
const unlabeledVersions = manifest.versions.filter((v) => !v.label);
|
|
1141
|
+
|
|
1142
|
+
// Check if pruning is needed
|
|
1143
|
+
if (unlabeledVersions.length <= config.maxVersions) {
|
|
1144
|
+
return { success: true };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Sort unlabeled versions by timestamp (oldest first for deletion)
|
|
1148
|
+
const sortedUnlabeled = [...unlabeledVersions].sort(
|
|
1149
|
+
(a, b) =>
|
|
1150
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
// Calculate how many to delete
|
|
1154
|
+
const toDelete = sortedUnlabeled.slice(
|
|
1155
|
+
0,
|
|
1156
|
+
unlabeledVersions.length - config.maxVersions
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
// Delete old version files
|
|
1160
|
+
for (const version of toDelete) {
|
|
1161
|
+
const versionPath = getVersionFilePath(storagePath, version.id);
|
|
1162
|
+
try {
|
|
1163
|
+
if (existsSync(versionPath)) {
|
|
1164
|
+
await unlink(versionPath);
|
|
1165
|
+
}
|
|
1166
|
+
} catch {
|
|
1167
|
+
// Ignore errors for individual files
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Update manifest - keep labeled + remaining unlabeled
|
|
1172
|
+
const remainingUnlabeled = sortedUnlabeled.slice(
|
|
1173
|
+
unlabeledVersions.length - config.maxVersions
|
|
1174
|
+
);
|
|
1175
|
+
manifest.versions = [...labeledVersions, ...remainingUnlabeled].sort(
|
|
1176
|
+
(a, b) =>
|
|
1177
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
1178
|
+
);
|
|
1179
|
+
manifest.updatedAt = new Date().toISOString();
|
|
1180
|
+
|
|
1181
|
+
await writeManifest(storagePath, manifest);
|
|
1182
|
+
|
|
1183
|
+
return { success: true };
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1186
|
+
console.error(`[writenex] Failed to prune versions:`, error);
|
|
1187
|
+
return { success: false, error: `Failed to prune versions: ${message}` };
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
export async function pruneVersions(
|
|
1192
|
+
projectRoot: string,
|
|
1193
|
+
collection: string,
|
|
1194
|
+
contentId: string,
|
|
1195
|
+
config: Required<VersionHistoryConfig>
|
|
1196
|
+
): Promise<VersionResult> {
|
|
1197
|
+
const storagePath = getVersionStoragePath(
|
|
1198
|
+
projectRoot,
|
|
1199
|
+
collection,
|
|
1200
|
+
contentId,
|
|
1201
|
+
config
|
|
1202
|
+
);
|
|
1203
|
+
|
|
1204
|
+
// Check if storage directory exists (no lock needed for this check)
|
|
1205
|
+
if (!existsSync(storagePath)) {
|
|
1206
|
+
return { success: true };
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Use lock to prevent concurrent manifest modifications
|
|
1210
|
+
return withLock(storagePath, () =>
|
|
1211
|
+
pruneVersionsInternal(storagePath, config)
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// =============================================================================
|
|
1216
|
+
// Restore Operations
|
|
1217
|
+
// =============================================================================
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Restore a version to current content.
|
|
1221
|
+
*
|
|
1222
|
+
* This function:
|
|
1223
|
+
* 1. Creates a safety snapshot of the current content before restoring
|
|
1224
|
+
* 2. Reads the version content to restore
|
|
1225
|
+
* 3. Overwrites the current content file with the version content
|
|
1226
|
+
*
|
|
1227
|
+
* Note: Cache invalidation should be handled by the caller (API route)
|
|
1228
|
+
* since the cache is managed at the server level.
|
|
1229
|
+
*
|
|
1230
|
+
* @param projectRoot - Absolute path to project root
|
|
1231
|
+
* @param collection - Collection name
|
|
1232
|
+
* @param contentId - Content item ID (slug)
|
|
1233
|
+
* @param versionId - Version ID to restore
|
|
1234
|
+
* @param contentFilePath - Absolute path to the current content file
|
|
1235
|
+
* @param config - Version history configuration
|
|
1236
|
+
* @param options - Restore options
|
|
1237
|
+
* @returns Result of the restore operation
|
|
1238
|
+
*
|
|
1239
|
+
* @example
|
|
1240
|
+
* ```typescript
|
|
1241
|
+
* const result = await restoreVersion(
|
|
1242
|
+
* '/project',
|
|
1243
|
+
* 'blog',
|
|
1244
|
+
* 'my-post',
|
|
1245
|
+
* '2024-12-11T10-30-00-000Z',
|
|
1246
|
+
* '/project/src/content/blog/my-post.md',
|
|
1247
|
+
* { enabled: true, maxVersions: 20, storagePath: '.writenex/versions' }
|
|
1248
|
+
* );
|
|
1249
|
+
*
|
|
1250
|
+
* if (result.success) {
|
|
1251
|
+
* console.log('Restored content:', result.content);
|
|
1252
|
+
* if (result.safetySnapshot) {
|
|
1253
|
+
* console.log('Safety snapshot created:', result.safetySnapshot.id);
|
|
1254
|
+
* }
|
|
1255
|
+
* }
|
|
1256
|
+
* ```
|
|
1257
|
+
*/
|
|
1258
|
+
export async function restoreVersion(
|
|
1259
|
+
projectRoot: string,
|
|
1260
|
+
collection: string,
|
|
1261
|
+
contentId: string,
|
|
1262
|
+
versionId: string,
|
|
1263
|
+
contentFilePath: string,
|
|
1264
|
+
config: Required<VersionHistoryConfig>,
|
|
1265
|
+
options: RestoreVersionOptions = {}
|
|
1266
|
+
): Promise<RestoreResult> {
|
|
1267
|
+
const { safetySnapshotLabel = "Before restore", skipSafetySnapshot = false } =
|
|
1268
|
+
options;
|
|
1269
|
+
|
|
1270
|
+
try {
|
|
1271
|
+
// Step 1: Get the version to restore
|
|
1272
|
+
const versionToRestore = await getVersion(
|
|
1273
|
+
projectRoot,
|
|
1274
|
+
collection,
|
|
1275
|
+
contentId,
|
|
1276
|
+
versionId,
|
|
1277
|
+
config
|
|
1278
|
+
);
|
|
1279
|
+
|
|
1280
|
+
if (!versionToRestore) {
|
|
1281
|
+
return {
|
|
1282
|
+
success: false,
|
|
1283
|
+
error: `Version not found: ${versionId}`,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Step 2: Read current content and create safety snapshot
|
|
1288
|
+
let safetySnapshot: VersionEntry | undefined;
|
|
1289
|
+
|
|
1290
|
+
if (!skipSafetySnapshot && existsSync(contentFilePath)) {
|
|
1291
|
+
try {
|
|
1292
|
+
const currentContent = await readFile(contentFilePath, "utf-8");
|
|
1293
|
+
|
|
1294
|
+
// Create safety snapshot with label
|
|
1295
|
+
const snapshotResult = await saveVersion(
|
|
1296
|
+
projectRoot,
|
|
1297
|
+
collection,
|
|
1298
|
+
contentId,
|
|
1299
|
+
currentContent,
|
|
1300
|
+
config,
|
|
1301
|
+
{ label: safetySnapshotLabel }
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
if (snapshotResult.success && snapshotResult.version) {
|
|
1305
|
+
safetySnapshot = snapshotResult.version;
|
|
1306
|
+
}
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
// Log warning but continue with restore
|
|
1309
|
+
console.warn(
|
|
1310
|
+
`[writenex] Failed to create safety snapshot before restore:`,
|
|
1311
|
+
error
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Step 3: Overwrite current content file with version content
|
|
1317
|
+
await writeFile(contentFilePath, versionToRestore.content, "utf-8");
|
|
1318
|
+
|
|
1319
|
+
return {
|
|
1320
|
+
success: true,
|
|
1321
|
+
version: {
|
|
1322
|
+
id: versionToRestore.id,
|
|
1323
|
+
timestamp: versionToRestore.timestamp,
|
|
1324
|
+
preview: versionToRestore.preview,
|
|
1325
|
+
size: versionToRestore.size,
|
|
1326
|
+
...(versionToRestore.label ? { label: versionToRestore.label } : {}),
|
|
1327
|
+
},
|
|
1328
|
+
content: versionToRestore.content,
|
|
1329
|
+
safetySnapshot,
|
|
1330
|
+
};
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1333
|
+
console.error(`[writenex] Failed to restore version:`, error);
|
|
1334
|
+
return {
|
|
1335
|
+
success: false,
|
|
1336
|
+
error: `Failed to restore version: ${message}`,
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
}
|