openwriter 0.26.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.
- package/dist/client/assets/index-BJMpYpj1.css +1 -0
- package/dist/client/assets/index-DgUPw-v5.js +214 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/package.json +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +8 -0
- package/dist/plugins/github/dist/blog-tools.js +792 -0
- package/dist/plugins/github/dist/git-sync.d.ts +36 -0
- package/dist/plugins/github/dist/git-sync.js +276 -0
- package/dist/plugins/github/dist/helpers.d.ts +84 -0
- package/dist/plugins/github/dist/helpers.js +62 -0
- package/dist/plugins/github/dist/index.d.ts +12 -0
- package/dist/plugins/github/dist/index.js +102 -0
- package/dist/plugins/github/package.json +24 -0
- package/dist/server/documents.js +119 -2
- package/dist/server/index.js +31 -11
- package/dist/server/markdown-parse.js +74 -1
- package/dist/server/mcp.js +215 -78
- package/dist/server/pending-metadata.js +65 -0
- package/dist/server/pending-overlay.js +151 -2
- package/dist/server/plugin-manager.js +18 -3
- package/dist/server/state.js +126 -39
- package/dist/server/ws.js +85 -26
- package/package.json +1 -1
- package/skill/SKILL.md +49 -19
- package/dist/client/assets/index-AWIKUHJ_.css +0 -1
- package/dist/client/assets/index-DmHLFNTs.js +0 -212
package/dist/server/documents.js
CHANGED
|
@@ -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
|
package/dist/server/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
115
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
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
|
-
|
|
1053
|
-
|
|
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
|
-
|
|
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
|
+
}
|