openwriter 0.8.8 → 0.9.2
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/dist/client/assets/index-BHiZqytt.js +210 -0
- package/dist/client/assets/{index-5Av_FKzU.css → index-BbzNoMAw.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/documents.js +139 -1
- package/dist/server/index.js +28 -2
- package/dist/server/mcp.js +7 -7
- package/dist/server/state.js +47 -8
- package/dist/server/workspace-routes.js +29 -1
- package/dist/server/workspaces.js +45 -0
- package/dist/server/ws.js +8 -3
- package/package.json +1 -1
- package/dist/client/assets/index-BwbbiqD9.js +0 -210
package/dist/client/index.html
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BHiZqytt.js"></script>
|
|
14
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BbzNoMAw.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
17
17
|
<div id="root"></div>
|
package/dist/server/documents.js
CHANGED
|
@@ -9,7 +9,7 @@ import matter from 'gray-matter';
|
|
|
9
9
|
import trash from 'trash';
|
|
10
10
|
import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
|
|
11
11
|
import { parseMarkdownContent } from './compact.js';
|
|
12
|
-
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, } from './state.js';
|
|
12
|
+
import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, } from './state.js';
|
|
13
13
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
|
|
14
14
|
import { ensureDocId } from './versions.js';
|
|
15
15
|
import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces } from './workspaces.js';
|
|
@@ -71,6 +71,22 @@ function writeDocOrder(order) {
|
|
|
71
71
|
export function reorderDocs(orderedFilenames) {
|
|
72
72
|
writeDocOrder(orderedFilenames);
|
|
73
73
|
}
|
|
74
|
+
/** Derive content_type from frontmatter — explicit field first, then fallback from context keys. */
|
|
75
|
+
function deriveContentType(data) {
|
|
76
|
+
if (data.content_type)
|
|
77
|
+
return data.content_type;
|
|
78
|
+
if (data.tweetContext)
|
|
79
|
+
return data.tweetContext.mode || 'tweet';
|
|
80
|
+
if (data.articleContext)
|
|
81
|
+
return 'article';
|
|
82
|
+
if (data.linkedinContext)
|
|
83
|
+
return 'linkedin';
|
|
84
|
+
if (data.newsletterContext)
|
|
85
|
+
return 'newsletter';
|
|
86
|
+
if (data.blogContext)
|
|
87
|
+
return 'blog';
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
74
90
|
export function listDocuments() {
|
|
75
91
|
ensureDataDir();
|
|
76
92
|
const currentPath = getFilePath();
|
|
@@ -103,6 +119,7 @@ export function listDocuments() {
|
|
|
103
119
|
...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : data.articleContext?.lastPost?.postedAt ? { lastSent: data.articleContext.lastPost.postedAt } : data.manualPost?.postedAt ? { lastSent: data.manualPost.postedAt } : {}),
|
|
104
120
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
105
121
|
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
122
|
+
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
106
123
|
};
|
|
107
124
|
}
|
|
108
125
|
catch {
|
|
@@ -140,6 +157,7 @@ export function listDocuments() {
|
|
|
140
157
|
...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : data.tweetContext?.lastPost?.postedAt ? { lastSent: data.tweetContext.lastPost.postedAt } : data.blogContext?.lastPublish?.publishedAt ? { lastSent: data.blogContext.lastPublish.publishedAt } : {}),
|
|
141
158
|
...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : {}),
|
|
142
159
|
...(data.newsletterContext ? { isNewsletter: true } : {}),
|
|
160
|
+
...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
|
|
143
161
|
});
|
|
144
162
|
}
|
|
145
163
|
catch { /* skip unreadable external files */ }
|
|
@@ -363,6 +381,8 @@ export function switchDocument(filename) {
|
|
|
363
381
|
save();
|
|
364
382
|
// Cache current doc before switching (preserves node IDs)
|
|
365
383
|
cacheActiveDocument();
|
|
384
|
+
// Reset version counter — new document starts a fresh version lineage
|
|
385
|
+
resetDocVersion();
|
|
366
386
|
// Read target from disk — markdownToTiptap rehydrates pending state
|
|
367
387
|
const targetPath = resolveDocPath(filename);
|
|
368
388
|
if (!existsSync(targetPath)) {
|
|
@@ -673,3 +693,121 @@ export function promoteTempFile(newTitle) {
|
|
|
673
693
|
renameMark(oldFilename, newFilename);
|
|
674
694
|
return newFilename;
|
|
675
695
|
}
|
|
696
|
+
// ============================================================================
|
|
697
|
+
// BATCH RESOLVE — accept/reject pending changes across multiple docs
|
|
698
|
+
// ============================================================================
|
|
699
|
+
const PENDING_ATTRS = ['pendingStatus', 'pendingOriginalContent', 'pendingGroupId', 'pendingSelectionFrom', 'pendingSelectionTo', 'pendingOriginalFrom', 'pendingOriginalTo'];
|
|
700
|
+
function clearPendingAttrs(attrs) {
|
|
701
|
+
const clean = { ...attrs };
|
|
702
|
+
for (const key of PENDING_ATTRS)
|
|
703
|
+
delete clean[key];
|
|
704
|
+
return clean;
|
|
705
|
+
}
|
|
706
|
+
/** Walk TipTap JSON, accept all pending changes in-place. Returns count of resolved nodes. */
|
|
707
|
+
function acceptAllInDoc(doc) {
|
|
708
|
+
let count = 0;
|
|
709
|
+
function walk(nodes) {
|
|
710
|
+
const result = [];
|
|
711
|
+
for (const node of nodes) {
|
|
712
|
+
const status = node.attrs?.pendingStatus;
|
|
713
|
+
if (status === 'delete') {
|
|
714
|
+
count++;
|
|
715
|
+
continue; // Remove delete nodes
|
|
716
|
+
}
|
|
717
|
+
if (status === 'insert' || status === 'rewrite') {
|
|
718
|
+
node.attrs = clearPendingAttrs(node.attrs);
|
|
719
|
+
count++;
|
|
720
|
+
}
|
|
721
|
+
if (node.content) {
|
|
722
|
+
node.content = walk(node.content);
|
|
723
|
+
}
|
|
724
|
+
result.push(node);
|
|
725
|
+
}
|
|
726
|
+
return result;
|
|
727
|
+
}
|
|
728
|
+
if (doc.content)
|
|
729
|
+
doc.content = walk(doc.content);
|
|
730
|
+
return count;
|
|
731
|
+
}
|
|
732
|
+
/** Walk TipTap JSON, reject all pending changes in-place. Returns count of resolved nodes. */
|
|
733
|
+
function rejectAllInDoc(doc) {
|
|
734
|
+
let count = 0;
|
|
735
|
+
function walk(nodes) {
|
|
736
|
+
const result = [];
|
|
737
|
+
for (const node of nodes) {
|
|
738
|
+
const status = node.attrs?.pendingStatus;
|
|
739
|
+
if (status === 'insert') {
|
|
740
|
+
count++;
|
|
741
|
+
continue; // Remove inserted nodes
|
|
742
|
+
}
|
|
743
|
+
if (status === 'rewrite') {
|
|
744
|
+
const original = node.attrs?.pendingOriginalContent;
|
|
745
|
+
if (original) {
|
|
746
|
+
// Replace with original content
|
|
747
|
+
result.push(original);
|
|
748
|
+
}
|
|
749
|
+
// If no original, just drop the node
|
|
750
|
+
count++;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (status === 'delete') {
|
|
754
|
+
// Keep the node, just clear pending status
|
|
755
|
+
node.attrs = clearPendingAttrs(node.attrs);
|
|
756
|
+
count++;
|
|
757
|
+
}
|
|
758
|
+
if (node.content) {
|
|
759
|
+
node.content = walk(node.content);
|
|
760
|
+
}
|
|
761
|
+
result.push(node);
|
|
762
|
+
}
|
|
763
|
+
return result;
|
|
764
|
+
}
|
|
765
|
+
if (doc.content)
|
|
766
|
+
doc.content = walk(doc.content);
|
|
767
|
+
return count;
|
|
768
|
+
}
|
|
769
|
+
/** Resolve a single doc file on disk. Returns number of changes resolved. */
|
|
770
|
+
function resolveDocFile(filePath, action) {
|
|
771
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
772
|
+
const { data } = matter(raw);
|
|
773
|
+
// Skip docs with no pending changes
|
|
774
|
+
if (!data.pending)
|
|
775
|
+
return 0;
|
|
776
|
+
// Pass full raw file — markdownToTiptap calls matter() internally and rehydrates pending state
|
|
777
|
+
const parsed = markdownToTiptap(raw);
|
|
778
|
+
const doc = parsed.document;
|
|
779
|
+
const count = action === 'accept' ? acceptAllInDoc(doc) : rejectAllInDoc(doc);
|
|
780
|
+
if (count === 0)
|
|
781
|
+
return 0;
|
|
782
|
+
// Re-serialize — pending attrs are cleared so pending key will be removed from frontmatter
|
|
783
|
+
const newRaw = tiptapToMarkdown(doc, parsed.title, parsed.metadata);
|
|
784
|
+
atomicWriteFileSync(filePath, newRaw);
|
|
785
|
+
return count;
|
|
786
|
+
}
|
|
787
|
+
export function batchResolve(filenames, action) {
|
|
788
|
+
let docsResolved = 0;
|
|
789
|
+
let changesResolved = 0;
|
|
790
|
+
for (const filename of filenames) {
|
|
791
|
+
const filePath = isExternalDoc(filename) ? filename : join(getDataDir(), filename);
|
|
792
|
+
if (!existsSync(filePath))
|
|
793
|
+
continue;
|
|
794
|
+
try {
|
|
795
|
+
const count = resolveDocFile(filePath, action);
|
|
796
|
+
if (count > 0) {
|
|
797
|
+
docsResolved++;
|
|
798
|
+
changesResolved += count;
|
|
799
|
+
// Active doc: update in-memory state directly (no reload flicker)
|
|
800
|
+
if (filePath === getFilePath()) {
|
|
801
|
+
const currentDoc = getDocument();
|
|
802
|
+
if (action === 'accept')
|
|
803
|
+
acceptAllInDoc(currentDoc);
|
|
804
|
+
else
|
|
805
|
+
rejectAllInDoc(currentDoc);
|
|
806
|
+
save();
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
catch { /* skip unreadable files */ }
|
|
811
|
+
}
|
|
812
|
+
return { docsResolved, changesResolved };
|
|
813
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -11,9 +11,9 @@ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadc
|
|
|
11
11
|
import { TOOL_REGISTRY } from './mcp.js';
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
14
|
-
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, clearAllCaches } from './state.js';
|
|
14
|
+
import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches } from './state.js';
|
|
15
15
|
import { syncPostHistory } from './post-sync.js';
|
|
16
|
-
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename } from './documents.js';
|
|
16
|
+
import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve } from './documents.js';
|
|
17
17
|
import { createWorkspaceRouter } from './workspace-routes.js';
|
|
18
18
|
import { createLinkRouter } from './link-routes.js';
|
|
19
19
|
import { createTweetRouter } from './tweet-routes.js';
|
|
@@ -267,6 +267,32 @@ export async function startHttpServer(options = {}) {
|
|
|
267
267
|
res.status(400).json({ error: err.message });
|
|
268
268
|
}
|
|
269
269
|
});
|
|
270
|
+
app.post('/api/documents/batch-resolve', (req, res) => {
|
|
271
|
+
try {
|
|
272
|
+
const { filenames, action } = req.body;
|
|
273
|
+
if (!Array.isArray(filenames) || !filenames.length) {
|
|
274
|
+
res.status(400).json({ error: 'filenames array is required' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (action !== 'accept' && action !== 'reject') {
|
|
278
|
+
res.status(400).json({ error: 'action must be "accept" or "reject"' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const result = batchResolve(filenames, action);
|
|
282
|
+
if (result.docsResolved > 0) {
|
|
283
|
+
// Clear pending cache for resolved docs + broadcast
|
|
284
|
+
for (const fn of filenames)
|
|
285
|
+
removePendingCacheEntry(fn);
|
|
286
|
+
updatePendingCacheForActiveDoc();
|
|
287
|
+
broadcastPendingDocsChanged();
|
|
288
|
+
broadcastDocumentsChanged();
|
|
289
|
+
}
|
|
290
|
+
res.json(result);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
res.status(400).json({ error: err.message });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
270
296
|
app.post('/api/documents/open', (req, res) => {
|
|
271
297
|
try {
|
|
272
298
|
const { path } = req.body;
|
package/dist/server/mcp.js
CHANGED
|
@@ -26,13 +26,13 @@ import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
|
|
|
26
26
|
/** Map a content type string to its frontmatter metadata object. */
|
|
27
27
|
function resolveTypeMeta(type, url) {
|
|
28
28
|
switch (type) {
|
|
29
|
-
case 'tweet': return { tweetContext: { mode: 'tweet' } };
|
|
30
|
-
case 'reply': return { tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
|
|
31
|
-
case 'quote': return { tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
|
|
32
|
-
case 'article': return { articleContext: { active: true } };
|
|
33
|
-
case 'linkedin': return { linkedinContext: { active: true } };
|
|
34
|
-
case 'newsletter': return { newsletterContext: { active: true } };
|
|
35
|
-
case 'blog': return { blogContext: { active: true } };
|
|
29
|
+
case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
|
|
30
|
+
case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
|
|
31
|
+
case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
|
|
32
|
+
case 'article': return { content_type: 'article', articleContext: { active: true } };
|
|
33
|
+
case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
|
|
34
|
+
case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
|
|
35
|
+
case 'blog': return { content_type: 'blog', blogContext: { active: true } };
|
|
36
36
|
default: return undefined;
|
|
37
37
|
}
|
|
38
38
|
}
|
package/dist/server/state.js
CHANGED
|
@@ -456,6 +456,24 @@ export function setAgentLock() {
|
|
|
456
456
|
export function isAgentLocked() {
|
|
457
457
|
return Date.now() - lastAgentWriteTime < AGENT_LOCK_MS;
|
|
458
458
|
}
|
|
459
|
+
// ---- Document version counter: prevents stale browser doc-updates ----
|
|
460
|
+
let docVersion = 0;
|
|
461
|
+
/** Increment version after agent writes. Returns the new version. */
|
|
462
|
+
export function bumpDocVersion() {
|
|
463
|
+
return ++docVersion;
|
|
464
|
+
}
|
|
465
|
+
/** Get current document version. */
|
|
466
|
+
export function getDocVersion() {
|
|
467
|
+
return docVersion;
|
|
468
|
+
}
|
|
469
|
+
/** Check if a browser doc-update version is current. */
|
|
470
|
+
export function isVersionCurrent(browserVersion) {
|
|
471
|
+
return browserVersion >= docVersion;
|
|
472
|
+
}
|
|
473
|
+
/** Reset version on document switch (new document = new version lineage). */
|
|
474
|
+
export function resetDocVersion() {
|
|
475
|
+
docVersion = 0;
|
|
476
|
+
}
|
|
459
477
|
// ---- Debounced save: coalesces rapid agent writes into a single disk write ----
|
|
460
478
|
let saveTimer = null;
|
|
461
479
|
const SAVE_DEBOUNCE_MS = 500;
|
|
@@ -477,11 +495,12 @@ export function cancelDebouncedSave() {
|
|
|
477
495
|
export function applyChanges(changes) {
|
|
478
496
|
// Apply to server-side document (source of truth)
|
|
479
497
|
const processed = applyChangesToDocument(changes);
|
|
480
|
-
//
|
|
498
|
+
// Bump version + lock browser doc-updates to prevent stale state overwrite
|
|
499
|
+
const version = bumpDocVersion();
|
|
481
500
|
setAgentLock();
|
|
482
|
-
// Broadcast processed changes (with server-assigned IDs) to browser clients
|
|
501
|
+
// Broadcast processed changes (with server-assigned IDs + version) to browser clients
|
|
483
502
|
for (const listener of listeners) {
|
|
484
|
-
listener(processed);
|
|
503
|
+
listener(processed, version);
|
|
485
504
|
}
|
|
486
505
|
// Debounced save — coalesces rapid agent writes into a single disk write
|
|
487
506
|
debouncedSave();
|
|
@@ -543,6 +562,10 @@ function findNodeInDoc(nodes, id) {
|
|
|
543
562
|
*/
|
|
544
563
|
function applyChangesToDoc(doc, changes) {
|
|
545
564
|
const processed = [];
|
|
565
|
+
// Track last insert anchor → last inserted node ID, so consecutive inserts
|
|
566
|
+
// with the same afterNodeId chain naturally (array order = document order).
|
|
567
|
+
let lastInsertAnchor = null;
|
|
568
|
+
let lastInsertedId = null;
|
|
546
569
|
for (const change of changes) {
|
|
547
570
|
if (change.operation === 'rewrite' && change.nodeId && change.content) {
|
|
548
571
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
@@ -608,6 +631,12 @@ function applyChangesToDoc(doc, changes) {
|
|
|
608
631
|
// Mark leaf blocks as pending (not containers) for correct serialization
|
|
609
632
|
markLeafBlocksAsPending(contentWithIds, 'insert');
|
|
610
633
|
let resolvedAfterId;
|
|
634
|
+
// Auto-chain: if this insert targets the same anchor as the previous insert,
|
|
635
|
+
// redirect it to insert after the last inserted node instead (preserves array order).
|
|
636
|
+
let effectiveAfterId = change.afterNodeId;
|
|
637
|
+
if (effectiveAfterId && effectiveAfterId === lastInsertAnchor && lastInsertedId) {
|
|
638
|
+
effectiveAfterId = lastInsertedId;
|
|
639
|
+
}
|
|
611
640
|
if (change.nodeId && !change.afterNodeId) {
|
|
612
641
|
// Replace empty node
|
|
613
642
|
const found = findNode(doc.content, change.nodeId, doc.content);
|
|
@@ -615,8 +644,8 @@ function applyChangesToDoc(doc, changes) {
|
|
|
615
644
|
continue;
|
|
616
645
|
found.parent.splice(found.index, 1, ...contentWithIds);
|
|
617
646
|
}
|
|
618
|
-
else if (
|
|
619
|
-
const found = findNode(doc.content,
|
|
647
|
+
else if (effectiveAfterId) {
|
|
648
|
+
const found = findNode(doc.content, effectiveAfterId, doc.content);
|
|
620
649
|
if (!found)
|
|
621
650
|
continue;
|
|
622
651
|
// Resolve "end" sentinel to actual node ID so browser can find it
|
|
@@ -626,11 +655,21 @@ function applyChangesToDoc(doc, changes) {
|
|
|
626
655
|
else {
|
|
627
656
|
continue;
|
|
628
657
|
}
|
|
629
|
-
//
|
|
658
|
+
// Track for auto-chaining: remember original anchor + last inserted ID
|
|
659
|
+
const newLastId = contentWithIds[contentWithIds.length - 1]?.attrs?.id;
|
|
660
|
+
if (change.afterNodeId && newLastId) {
|
|
661
|
+
if (change.afterNodeId !== lastInsertAnchor) {
|
|
662
|
+
// New anchor — start fresh chain
|
|
663
|
+
lastInsertAnchor = change.afterNodeId;
|
|
664
|
+
}
|
|
665
|
+
lastInsertedId = newLastId;
|
|
666
|
+
}
|
|
667
|
+
// Broadcast with server-assigned IDs and resolved anchor so browser inserts at the correct position
|
|
630
668
|
processed.push({
|
|
631
669
|
...change,
|
|
632
|
-
|
|
633
|
-
|
|
670
|
+
afterNodeId: resolvedAfterId && change.afterNodeId === 'end'
|
|
671
|
+
? resolvedAfterId
|
|
672
|
+
: effectiveAfterId ?? change.afterNodeId,
|
|
634
673
|
content: contentWithIds.length === 1 ? contentWithIds[0] : contentWithIds,
|
|
635
674
|
});
|
|
636
675
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Mounted in index.ts to keep the main file lean.
|
|
4
4
|
*/
|
|
5
5
|
import { Router } from 'express';
|
|
6
|
-
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, } from './workspaces.js';
|
|
6
|
+
import { listWorkspaces, getWorkspace, createWorkspace, deleteWorkspace, reorderWorkspaces, addDoc, removeDoc, moveDoc, reorderDoc, addContainerToWorkspace, removeContainer, renameContainer, renameWorkspace, reorderContainer, crossMoveContainer, promoteContainerToWorkspace, } from './workspaces.js';
|
|
7
7
|
export function createWorkspaceRouter(b) {
|
|
8
8
|
const router = Router();
|
|
9
9
|
router.get('/api/workspaces', (_req, res) => {
|
|
@@ -36,6 +36,20 @@ export function createWorkspaceRouter(b) {
|
|
|
36
36
|
res.status(400).json({ error: err.message });
|
|
37
37
|
}
|
|
38
38
|
});
|
|
39
|
+
// Promote container to standalone workspace
|
|
40
|
+
router.post('/api/workspaces/promote-container', (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const { sourceWorkspace, containerId, afterWorkspaceFilename } = req.body;
|
|
43
|
+
if (!sourceWorkspace || !containerId)
|
|
44
|
+
return res.status(400).json({ error: 'sourceWorkspace and containerId required' });
|
|
45
|
+
const result = promoteContainerToWorkspace(sourceWorkspace, containerId, afterWorkspaceFilename ?? null);
|
|
46
|
+
b.broadcastWorkspacesChanged();
|
|
47
|
+
res.json(result);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
res.status(400).json({ error: err.message });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
39
53
|
router.get('/api/workspaces/:filename', (req, res) => {
|
|
40
54
|
try {
|
|
41
55
|
res.json(getWorkspace(req.params.filename));
|
|
@@ -149,6 +163,20 @@ export function createWorkspaceRouter(b) {
|
|
|
149
163
|
res.status(400).json({ error: err.message });
|
|
150
164
|
}
|
|
151
165
|
});
|
|
166
|
+
// Cross-workspace container move
|
|
167
|
+
router.post('/api/workspaces/:targetFilename/containers/:containerId/cross-move', (req, res) => {
|
|
168
|
+
try {
|
|
169
|
+
const { sourceWorkspace } = req.body;
|
|
170
|
+
if (!sourceWorkspace)
|
|
171
|
+
return res.status(400).json({ error: 'sourceWorkspace required' });
|
|
172
|
+
const ws = crossMoveContainer(sourceWorkspace, req.params.targetFilename, req.params.containerId, req.body.targetContainerId ?? null, req.body.afterIdentifier ?? null);
|
|
173
|
+
b.broadcastWorkspacesChanged();
|
|
174
|
+
res.json(ws);
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
res.status(400).json({ error: err.message });
|
|
178
|
+
}
|
|
179
|
+
});
|
|
152
180
|
// Cross-workspace move (from one workspace to another)
|
|
153
181
|
router.post('/api/workspaces/:targetFilename/docs/:docFile/cross-move', (req, res) => {
|
|
154
182
|
try {
|
|
@@ -252,6 +252,51 @@ export function moveContainer(wsFile, containerId, targetContainerId, afterIdent
|
|
|
252
252
|
writeWorkspace(wsFile, ws);
|
|
253
253
|
return ws;
|
|
254
254
|
}
|
|
255
|
+
export function crossMoveContainer(sourceWsFile, targetWsFile, containerId, targetContainerId, afterIdentifier) {
|
|
256
|
+
const sourceWs = getWorkspace(sourceWsFile);
|
|
257
|
+
const removed = removeNode(sourceWs.root, containerId);
|
|
258
|
+
if (removed.type !== 'container')
|
|
259
|
+
throw new Error(`Node "${containerId}" is not a container`);
|
|
260
|
+
writeWorkspace(sourceWsFile, sourceWs);
|
|
261
|
+
const targetWs = getWorkspace(targetWsFile);
|
|
262
|
+
// Insert into target — reuse moveNode-style logic
|
|
263
|
+
const target = targetContainerId === null
|
|
264
|
+
? targetWs.root
|
|
265
|
+
: (() => { const f = findContainer(targetWs.root, targetContainerId); if (!f)
|
|
266
|
+
throw new Error('Target container not found'); return f.node.items; })();
|
|
267
|
+
if (afterIdentifier === null) {
|
|
268
|
+
target.unshift(removed);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const afterIdx = target.findIndex((n) => (n.type === 'doc' && n.file === afterIdentifier) || (n.type === 'container' && n.id === afterIdentifier));
|
|
272
|
+
if (afterIdx === -1)
|
|
273
|
+
target.push(removed);
|
|
274
|
+
else
|
|
275
|
+
target.splice(afterIdx + 1, 0, removed);
|
|
276
|
+
}
|
|
277
|
+
writeWorkspace(targetWsFile, targetWs);
|
|
278
|
+
return targetWs;
|
|
279
|
+
}
|
|
280
|
+
export function promoteContainerToWorkspace(sourceWsFile, containerId, afterWorkspaceFilename) {
|
|
281
|
+
const sourceWs = getWorkspace(sourceWsFile);
|
|
282
|
+
const removed = removeNode(sourceWs.root, containerId);
|
|
283
|
+
if (removed.type !== 'container')
|
|
284
|
+
throw new Error(`Node "${containerId}" is not a container`);
|
|
285
|
+
writeWorkspace(sourceWsFile, sourceWs);
|
|
286
|
+
// Create new workspace with container's children as root
|
|
287
|
+
const slug = sanitizeFilename(removed.name).toLowerCase().replace(/\s+/g, '-');
|
|
288
|
+
const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
|
|
289
|
+
const workspace = { version: 2, title: removed.name, root: removed.items };
|
|
290
|
+
writeWorkspace(filename, workspace);
|
|
291
|
+
// Append to order then reposition
|
|
292
|
+
const order = readOrder();
|
|
293
|
+
order.push(filename);
|
|
294
|
+
writeOrder(order);
|
|
295
|
+
if (afterWorkspaceFilename !== null || order.length > 1) {
|
|
296
|
+
reorderWorkspaceAfter(filename, afterWorkspaceFilename);
|
|
297
|
+
}
|
|
298
|
+
return { filename, title: removed.name, docCount: countDocs(removed.items) };
|
|
299
|
+
}
|
|
255
300
|
export function reorderWorkspaceAfter(filename, afterFilename) {
|
|
256
301
|
ensureWorkspacesDir();
|
|
257
302
|
const order = readOrder();
|
package/dist/server/ws.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
|
|
3
3
|
*/
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
-
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
5
|
+
import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, setAgentLock, getDocVersion, isVersionCurrent, getPendingDocInfo, updatePendingCacheForActiveDoc, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
|
|
6
6
|
import { switchDocument, createDocument, deleteDocument, getActiveFilename, promoteTempFile } from './documents.js';
|
|
7
7
|
import { removeDocFromAllWorkspaces } from './workspaces.js';
|
|
8
8
|
const clients = new Set();
|
|
@@ -45,7 +45,7 @@ export function setupWebSocket(server) {
|
|
|
45
45
|
},
|
|
46
46
|
});
|
|
47
47
|
// Push agent changes to all browser clients
|
|
48
|
-
onChanges((changes) => {
|
|
48
|
+
onChanges((changes, version) => {
|
|
49
49
|
// Check if changes include HR nodes in a tweet thread document.
|
|
50
50
|
// Tweet editors don't support horizontalRule in their schema, so individual
|
|
51
51
|
// node-changes with HRs silently fail. Send a full document resync instead,
|
|
@@ -81,7 +81,7 @@ export function setupWebSocket(server) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
else {
|
|
84
|
-
const msg = JSON.stringify({ type: 'node-changes', changes });
|
|
84
|
+
const msg = JSON.stringify({ type: 'node-changes', changes, version });
|
|
85
85
|
for (const ws of clients) {
|
|
86
86
|
if (ws.readyState === WebSocket.OPEN)
|
|
87
87
|
ws.send(msg);
|
|
@@ -123,9 +123,14 @@ export function setupWebSocket(server) {
|
|
|
123
123
|
const docContent = msg.document?.content || [];
|
|
124
124
|
const nodeCount = docContent.length;
|
|
125
125
|
const currentNodeCount = getDocument()?.content?.length || 0;
|
|
126
|
+
const browserVersion = typeof msg.version === 'number' ? msg.version : -1;
|
|
127
|
+
const serverVersion = getDocVersion();
|
|
126
128
|
if (isAgentLocked()) {
|
|
127
129
|
console.log(`[WS] doc-update BLOCKED by agent lock (browser: ${nodeCount} nodes, server: ${currentNodeCount} nodes)`);
|
|
128
130
|
}
|
|
131
|
+
else if (browserVersion >= 0 && !isVersionCurrent(browserVersion)) {
|
|
132
|
+
console.log(`[WS] doc-update BLOCKED by stale version (browser: v${browserVersion}, server: v${serverVersion})`);
|
|
133
|
+
}
|
|
129
134
|
else if (msg.filename && msg.filename !== getActiveFilename()) {
|
|
130
135
|
// Browser sent a doc-update for a different document (race: server switched away).
|
|
131
136
|
// Save directly to that file on disk instead of corrupting the active doc.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|