openwriter 0.25.0 → 0.27.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.
@@ -16,6 +16,8 @@ import { renameDocInAllWorkspaces, removeDocFromAllWorkspaces, listWorkspaces, g
16
16
  import { collectAllFiles } from './workspace-tree.js';
17
17
  import { renameComments } from './comments.js';
18
18
  import { deleteOverlay, diagLog } from './pending-overlay.js';
19
+ import { loadPendingMetadata, savePendingMetadata } from './pending-metadata.js';
20
+ import { getPendingMetadata as getActivePendingMetadata, setPendingMetadata as setActivePendingMetadata, getDocVersion } from './state.js';
19
21
  import { getDocId as getActiveDocId } from './state.js';
20
22
  function getDocOrderFile() { return join(getDataDir(), '_doc-order.json'); }
21
23
  /** Scan files for matching docId. Checks active doc first (free), then getDataDir(), then external docs. */
@@ -119,7 +121,7 @@ export function listDocuments() {
119
121
  isActive: fullPath === currentPath,
120
122
  ...(data.docId ? { docId: data.docId } : {}),
121
123
  ...(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 } : {}),
122
- ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : data.articleContext?.lastPost?.tweetUrl ? { postedUrl: data.articleContext.lastPost.tweetUrl } : {}),
124
+ ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : data.articleContext?.lastPost?.tweetUrl ? { postedUrl: data.articleContext.lastPost.tweetUrl } : data.blogContext?.lastPublish?.publishedUrl ? { postedUrl: data.blogContext.lastPublish.publishedUrl } : {}),
123
125
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
124
126
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
125
127
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
@@ -175,7 +177,7 @@ export function listDocuments() {
175
177
  isActive: extPath === currentPath,
176
178
  ...(data.docId ? { docId: data.docId } : {}),
177
179
  ...(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 } : {}),
178
- ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : data.articleContext?.lastPost?.tweetUrl ? { postedUrl: data.articleContext.lastPost.tweetUrl } : {}),
180
+ ...(data.tweetContext?.lastPost?.tweetUrl ? { postedUrl: data.tweetContext.lastPost.tweetUrl } : data.articleContext?.lastPost?.tweetUrl ? { postedUrl: data.articleContext.lastPost.tweetUrl } : data.blogContext?.lastPublish?.publishedUrl ? { postedUrl: data.blogContext.lastPublish.publishedUrl } : {}),
179
181
  ...(data.newsletterContext ? { isNewsletter: true } : {}),
180
182
  ...(deriveContentType(data) ? { contentType: deriveContentType(data) } : {}),
181
183
  ...(data.masterDocId ? { masterDocId: data.masterDocId } : {}),
@@ -946,6 +948,121 @@ export function updateDocumentTitle(filename, newTitle) {
946
948
  setActiveDocument(getDocument(), newTitle, filePath, baseName.startsWith(TEMP_PREFIX), undefined, metadata);
947
949
  }
948
950
  }
951
+ // ============================================================================
952
+ // PENDING TITLE STAGING (agent-initiated renames gated through pending review)
953
+ // ============================================================================
954
+ //
955
+ // Agent-side renames (MCP rename_item, set_metadata with a title field) route
956
+ // here instead of calling updateDocumentTitle directly. The proposal lands in
957
+ // the per-doc sidecar's `metadata:` slot; the .md file's frontmatter is
958
+ // unchanged on disk; the user accepts or rejects via the title-bar inline
959
+ // diff. User-typed renames (HTTP PUT /api/documents/:filename) and creation-
960
+ // time titling (populate_document) still write hot — they're the user
961
+ // disposing, not the agent proposing.
962
+ //
963
+ // adr: adr/pending-overlay-model.md
964
+ /** Read the canonical current title for a doc without loading it into state.
965
+ * Active doc → in-memory; otherwise → parse the .md frontmatter from disk. */
966
+ function readCanonicalTitle(docId, filename) {
967
+ if (getActiveDocId() === docId) {
968
+ return getTitle();
969
+ }
970
+ const filePath = resolveDocPath(filename);
971
+ if (!existsSync(filePath)) {
972
+ throw new Error(`Document not found: ${filename}`);
973
+ }
974
+ const raw = readFileSync(filePath, 'utf-8');
975
+ const { data } = matter(raw);
976
+ return data.title || filename.replace(/\.md$/i, '');
977
+ }
978
+ /** Stage a pending title rename for the doc identified by `docId`. Writes
979
+ * to the sidecar (and to state.pendingMetadata when the doc is active).
980
+ * Does NOT touch the .md file on disk. Returns the resolved {from, to}
981
+ * pair for the caller's response message. */
982
+ export function stagePendingTitle(docId, newTitle) {
983
+ const filename = filenameByDocId(docId);
984
+ if (!filename) {
985
+ throw new Error(`Document not found: ${docId}`);
986
+ }
987
+ const from = readCanonicalTitle(docId, filename);
988
+ // Idempotency: if the proposal equals canonical, clear any pending entry
989
+ // and return — nothing to review.
990
+ if (newTitle === from) {
991
+ if (getActiveDocId() === docId) {
992
+ setActivePendingMetadata(null);
993
+ }
994
+ else {
995
+ savePendingMetadata(docId, null);
996
+ }
997
+ return { from, to: newTitle, filename };
998
+ }
999
+ const meta = {
1000
+ title: { from, to: newTitle, addedAtVersion: getDocVersion() },
1001
+ };
1002
+ if (getActiveDocId() === docId) {
1003
+ setActivePendingMetadata(meta);
1004
+ }
1005
+ else {
1006
+ savePendingMetadata(docId, meta);
1007
+ }
1008
+ diagLog(`[Overlay] PENDING-TITLE STAGE docId=${docId} from="${from}" to="${newTitle}"`);
1009
+ return { from, to: newTitle, filename };
1010
+ }
1011
+ /** Accept a staged title rename — promote it to canonical (writes through
1012
+ * updateDocumentTitle) and clear the pending entry. Returns the {from, to}
1013
+ * applied, or null if no pending title was staged for this doc. */
1014
+ export function acceptPendingTitle(docId) {
1015
+ const filename = filenameByDocId(docId);
1016
+ if (!filename)
1017
+ return null;
1018
+ const meta = (getActiveDocId() === docId)
1019
+ ? getActivePendingMetadata()
1020
+ : loadPendingMetadata(docId);
1021
+ if (!meta?.title)
1022
+ return null;
1023
+ const { from, to } = meta.title;
1024
+ // Order matters: clear pending FIRST so updateDocumentTitle's downstream
1025
+ // setActiveDocument re-rehydration sees an empty sidecar metadata slot.
1026
+ if (getActiveDocId() === docId) {
1027
+ setActivePendingMetadata(null);
1028
+ }
1029
+ else {
1030
+ savePendingMetadata(docId, null);
1031
+ }
1032
+ updateDocumentTitle(filename, to);
1033
+ diagLog(`[Overlay] PENDING-TITLE ACCEPT docId=${docId} from="${from}" to="${to}"`);
1034
+ return { from, to, filename };
1035
+ }
1036
+ /** Reject a staged title rename — discard the proposal without modifying
1037
+ * the .md file. Returns the {from, to} that was discarded, or null if no
1038
+ * pending title was staged. */
1039
+ export function rejectPendingTitle(docId) {
1040
+ const filename = filenameByDocId(docId);
1041
+ if (!filename)
1042
+ return null;
1043
+ const meta = (getActiveDocId() === docId)
1044
+ ? getActivePendingMetadata()
1045
+ : loadPendingMetadata(docId);
1046
+ if (!meta?.title)
1047
+ return null;
1048
+ const { from, to } = meta.title;
1049
+ if (getActiveDocId() === docId) {
1050
+ setActivePendingMetadata(null);
1051
+ }
1052
+ else {
1053
+ savePendingMetadata(docId, null);
1054
+ }
1055
+ diagLog(`[Overlay] PENDING-TITLE REJECT docId=${docId} from="${from}" to="${to}"`);
1056
+ return { from, to, filename };
1057
+ }
1058
+ /** Lookup helper: read the currently-staged pending title for a docId, or
1059
+ * null if no proposal exists. Active doc → in-memory; otherwise → sidecar. */
1060
+ export function getPendingTitle(docId) {
1061
+ const meta = (getActiveDocId() === docId)
1062
+ ? getActivePendingMetadata()
1063
+ : loadPendingMetadata(docId);
1064
+ return meta?.title ?? null;
1065
+ }
949
1066
  /** Open an existing file from any path. Saves current doc, registers as external, sets as active.
950
1067
  *
951
1068
  * Canonicalizes the input path at the boundary so opening the same physical
@@ -7,7 +7,7 @@ import { createServer } from 'http';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { dirname, join } from 'path';
9
9
  import { existsSync, readFileSync } from 'fs';
10
- import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastSyncStatus, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityLogSeed } from './ws.js';
10
+ import { setupWebSocket, broadcastAgentStatus, broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityLogSeed } from './ws.js';
11
11
  import { TOOL_REGISTRY } from './mcp.js';
12
12
  import { z } from 'zod';
13
13
  import { zodToJsonSchema } from 'zod-to-json-schema';
@@ -21,7 +21,6 @@ import { markdownToTiptap } from './markdown.js';
21
21
  import { importGoogleDoc } from './gdoc-import.js';
22
22
  import { createVersionRouter } from './version-routes.js';
23
23
  import { clearVersionsCache } from './versions.js';
24
- import { createSyncRouter } from './sync-routes.js';
25
24
  import { removeDocFromAllWorkspaces } from './workspaces.js';
26
25
  import { resolveDocPath, getActiveProfile, setActiveProfile, listProfiles, createProfile, deleteProfile, listTrashedProfiles, restoreProfile, saveConfig, readConfig } from './helpers.js';
27
26
  import { createImageRouter } from './image-upload.js';
@@ -29,7 +28,6 @@ import { createExportRouter } from './export-routes.js';
29
28
  import { createConnectionRouter } from './connection-routes.js';
30
29
  import { createSchedulerRouter } from './scheduler-routes.js';
31
30
  import { createBillingRouter } from './billing-routes.js';
32
- import { createBlogRouter } from './blog-routes.js';
33
31
  import { createTaskRouter } from './task-routes.js';
34
32
  import { platformFetch, isAuthenticated } from './connections.js';
35
33
  import { PluginManager } from './plugin-manager.js';
@@ -111,8 +109,8 @@ export async function startHttpServer(options = {}) {
111
109
  });
112
110
  // Mount image upload + static serving
113
111
  app.use(createImageRouter());
114
- // Mount sync routes
115
- app.use(createSyncRouter(broadcastSyncStatus));
112
+ // Sync routes now provided by @openwriter/plugin-github (auto-enabled below
113
+ // if installed). SyncButton + SyncSetupModal hit /api/sync/* unchanged.
116
114
  // Mount export routes
117
115
  app.use(createExportRouter());
118
116
  // Mount connection CRUD + profile binding routes
@@ -121,8 +119,6 @@ export async function startHttpServer(options = {}) {
121
119
  app.use(createSchedulerRouter());
122
120
  // Mount billing proxy routes
123
121
  app.use(createBillingRouter());
124
- // Mount blog publish routes
125
- app.use(createBlogRouter());
126
122
  // Mount task CRUD routes
127
123
  app.use(createTaskRouter());
128
124
  // Newsletter analytics proxy routes
@@ -1024,6 +1020,18 @@ export async function startHttpServer(options = {}) {
1024
1020
  console.error(`[Plugin] ${result.error}`);
1025
1021
  }
1026
1022
  }
1023
+ // Migration: existing docs-sync users (config.gitConfigured === true from
1024
+ // before the github-plugin lift) get the plugin auto-enabled so /api/sync/*
1025
+ // routes keep responding. Runs once — the enable() call persists the flag
1026
+ // into config.plugins for next boot.
1027
+ const ghName = '@openwriter/plugin-github';
1028
+ if (savedConfig.gitConfigured && !savedConfig.plugins?.[ghName]?.enabled) {
1029
+ const result = await pluginManager.enable(ghName);
1030
+ if (!result.success)
1031
+ console.error(`[Plugin migration] ${result.error}`);
1032
+ else
1033
+ console.log('[Plugin migration] auto-enabled @openwriter/plugin-github for existing sync user');
1034
+ }
1027
1035
  // Enabled plugins' context menu items (backward-compatible)
1028
1036
  app.get('/api/plugins', (_req, res) => {
1029
1037
  res.json({ plugins: pluginManager.getEnabledPluginDescriptors() });
@@ -1039,8 +1047,14 @@ export async function startHttpServer(options = {}) {
1039
1047
  res.status(400).json({ error: 'name is required' });
1040
1048
  return;
1041
1049
  }
1042
- const result = await pluginManager.enable(name);
1043
- res.json(result);
1050
+ try {
1051
+ const result = await pluginManager.enable(name);
1052
+ res.json(result);
1053
+ }
1054
+ catch (err) {
1055
+ console.error(`[Plugin] enable(${name}) threw:`, err?.message ?? err);
1056
+ res.status(500).json({ success: false, error: err?.message ?? String(err) });
1057
+ }
1044
1058
  });
1045
1059
  // Disable a plugin
1046
1060
  app.post('/api/plugins/disable', async (req, res) => {
@@ -1049,8 +1063,14 @@ export async function startHttpServer(options = {}) {
1049
1063
  res.status(400).json({ error: 'name is required' });
1050
1064
  return;
1051
1065
  }
1052
- const result = await pluginManager.disable(name);
1053
- res.json(result);
1066
+ try {
1067
+ const result = await pluginManager.disable(name);
1068
+ res.json(result);
1069
+ }
1070
+ catch (err) {
1071
+ console.error(`[Plugin] disable(${name}) threw:`, err?.message ?? err);
1072
+ res.status(500).json({ success: false, error: err?.message ?? String(err) });
1073
+ }
1054
1074
  });
1055
1075
  // Update plugin config
1056
1076
  app.post('/api/plugins/config', (req, res) => {
@@ -114,6 +114,22 @@ function normalizeTableBlankLines(markdown) {
114
114
  }
115
115
  return out.join('\n');
116
116
  }
117
+ /**
118
+ * Parse a raw markdown file into a TipTap document. Returns CANONICAL-ONLY
119
+ * — the per-docId sidecar overlay (`_pending/{docId}.json`) is NOT loaded
120
+ * here. Callers that want "the user-visible doc" (any read surface — MCP
121
+ * tools, HTTP fetches, anything that shows content to the user) must use
122
+ * `loadDocFromDisk` from pending-overlay.ts instead. Bare callers of this
123
+ * function are the persistence-layer internals — save-time matcher,
124
+ * sync-check roundtripping, on-disk identity reads — that deliberately
125
+ * want pre-overlay canonical shape.
126
+ *
127
+ * The legacy rehydrate at line 176 handles the pre-fb666e6 in-frontmatter
128
+ * `pending:` field for migration purposes only. New writes never produce
129
+ * that field; the sidecar is authoritative.
130
+ *
131
+ * adr: adr/pending-overlay-model.md
132
+ */
117
133
  export function markdownToTiptap(markdown) {
118
134
  const result = matter(markdown);
119
135
  const { data, content } = result;
@@ -328,7 +344,17 @@ function tokensToTiptap(tokens) {
328
344
  nodes.push(content[0]);
329
345
  }
330
346
  else {
331
- nodes.push({ type: 'paragraph', attrs: { id: id || generateNodeId() }, content });
347
+ // Heal `<br><br>` paragraph fusion. A run of 2+ consecutive hardBreaks
348
+ // visually renders as a blank-line gap — i.e. a paragraph break — and
349
+ // the tweet editor's TweetEnterHardBreak keymap actively prevents
350
+ // authoring this state at the keyboard. The only way it lands in a
351
+ // body is the pre-6d0a75e mergeParagraphsToHardBreaks behavior that
352
+ // collapsed tweet writes into a single node before serializing, so
353
+ // splitting on import restores the original per-paragraph review
354
+ // unit (and the next save writes clean `\n\n` to disk).
355
+ // Single hardBreaks stay inline — they're legitimate intra-paragraph
356
+ // soft breaks (tweet line break, poem line).
357
+ nodes.push(...splitParagraphOnDoubleBreaks(content, id));
332
358
  }
333
359
  i += 3;
334
360
  }
@@ -757,3 +783,50 @@ function extractTrailingNodeId(content) {
757
783
  }
758
784
  return { content: newContent, id };
759
785
  }
786
+ /**
787
+ * Split a paragraph's inline content at runs of 2+ consecutive `hardBreak`
788
+ * nodes. Returns one paragraph node per logical chunk. The first paragraph
789
+ * keeps the passed-in id (when present); the rest get fresh ids. Single
790
+ * `hardBreak`s pass through untouched.
791
+ *
792
+ * Empty groups (runs of breaks at the start/end, or back-to-back split points)
793
+ * are dropped, but at least one paragraph is always returned so an
794
+ * all-breaks input still serializes as an empty paragraph rather than
795
+ * vanishing.
796
+ */
797
+ function splitParagraphOnDoubleBreaks(content, firstId) {
798
+ if (!content || content.length === 0) {
799
+ return [{ type: 'paragraph', attrs: { id: firstId || generateNodeId() }, content: [] }];
800
+ }
801
+ const groups = [];
802
+ let current = [];
803
+ let i = 0;
804
+ while (i < content.length) {
805
+ if (content[i].type === 'hardBreak') {
806
+ let j = i;
807
+ while (j < content.length && content[j].type === 'hardBreak')
808
+ j++;
809
+ const runLen = j - i;
810
+ if (runLen >= 2) {
811
+ groups.push(current);
812
+ current = [];
813
+ }
814
+ else {
815
+ current.push(content[i]);
816
+ }
817
+ i = j;
818
+ }
819
+ else {
820
+ current.push(content[i]);
821
+ i++;
822
+ }
823
+ }
824
+ groups.push(current);
825
+ const nonEmpty = groups.filter((g) => g.length > 0);
826
+ const final = nonEmpty.length > 0 ? nonEmpty : [[]];
827
+ return final.map((g, idx) => ({
828
+ type: 'paragraph',
829
+ attrs: { id: idx === 0 ? (firstId || generateNodeId()) : generateNodeId() },
830
+ content: g,
831
+ }));
832
+ }