openwriter 0.29.0 → 0.29.1

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.
@@ -10,7 +10,7 @@
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-SAoCjUU-.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-DHaZI7nA.js"></script>
14
14
  <link rel="stylesheet" crossorigin href="/assets/index-Gdw1m46J.css">
15
15
  </head>
16
16
  <body>
@@ -0,0 +1,20 @@
1
+ // Single source of truth for content-type scaffolding metadata. Maps a
2
+ // content_type to the frontmatter it needs: the `content_type` field itself
3
+ // (which owns the editor surface — adr: adr/browser-write-fidelity.md) plus the
4
+ // type's context object (tweetContext / articleContext / blogContext / …).
5
+ //
6
+ // Used by both the MCP create_document handler and the HTTP POST /api/documents
7
+ // endpoint (the "Create variant" path) so a typed empty doc is scaffolded the
8
+ // same way regardless of who creates it. adr: docs/variants.md
9
+ export function resolveTypeMeta(type, url) {
10
+ switch (type) {
11
+ case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
12
+ case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
13
+ case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
14
+ case 'article': return { content_type: 'article', articleContext: { active: true } };
15
+ case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
16
+ case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
17
+ case 'blog': return { content_type: 'blog', blogContext: { active: true } };
18
+ default: return undefined;
19
+ }
20
+ }
@@ -8,6 +8,7 @@ import { join } from 'path';
8
8
  import matter from 'gray-matter';
9
9
  import trash from 'trash';
10
10
  import { tiptapToMarkdownChecked, markdownToTiptap } from './markdown.js';
11
+ import { resolveTypeMeta } from './content-type-meta.js';
11
12
  import { parseMarkdownContent } from './compact.js';
12
13
  import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, resetDocVersion, markAsAgentStub, unmarkAgentStub, isAgentStub, } from './state.js';
13
14
  import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath } from './helpers.js';
@@ -1148,6 +1149,77 @@ export function duplicateDocument(filename, variant) {
1148
1149
  const newFilename = filePath.split(/[/\\]/).pop();
1149
1150
  return { document: getDocument(), title: getTitle(), filename: newFilename };
1150
1151
  }
1152
+ // Content types that surface an editable title/headline above the body. For
1153
+ // these, the doc's frontmatter `title` IS content (blog headline, article title,
1154
+ // newsletter subject). For every other type the title is just a sidebar label
1155
+ // and the body carries everything. adr: docs/variants.md
1156
+ const TITLE_BEARING_TYPES = new Set(['blog', 'article', 'newsletter']);
1157
+ /**
1158
+ * Create a variant of `masterFilename` retyped as `variantType`, nested under
1159
+ * the master. Field-projection model (NOT a verbatim clone — that's
1160
+ * duplicateDocument): port the fields the two types share.
1161
+ * - body: always ported.
1162
+ * - downcast (title-bearing master → body-only variant): the master's title is
1163
+ * folded into the body as its first paragraph so the headline isn't lost.
1164
+ * - the variant is scaffolded with the TARGET type's content_type + context;
1165
+ * the source's context objects (blogContext, tweetContext, …) are NOT
1166
+ * inherited — a variant is a new typed doc, not a surface clone.
1167
+ * adr: docs/variants.md
1168
+ */
1169
+ export function createVariant(masterFilename, opts) {
1170
+ cancelDebouncedSave();
1171
+ save();
1172
+ const sourcePath = resolveDocPath(masterFilename);
1173
+ if (!existsSync(sourcePath))
1174
+ throw new Error(`Document not found: ${masterFilename}`);
1175
+ const raw = readFileSync(sourcePath, 'utf-8');
1176
+ const parsed = markdownToTiptap(raw);
1177
+ const srcType = deriveContentType(parsed.metadata) || 'document';
1178
+ const tgtType = opts.variantType;
1179
+ const srcTitleBearing = TITLE_BEARING_TYPES.has(srcType);
1180
+ const tgtTitleBearing = TITLE_BEARING_TYPES.has(tgtType);
1181
+ // Body projection. Downcast (title-bearing → body-only): prepend the master's
1182
+ // title as the first paragraph so the headline survives in a surface with no
1183
+ // title field ("title becomes first line, body the next paragraph"). Otherwise
1184
+ // the body ports unchanged.
1185
+ let bodyContent = parsed.document.content || [];
1186
+ if (srcTitleBearing && !tgtTitleBearing && parsed.title) {
1187
+ bodyContent = [
1188
+ { type: 'paragraph', content: [{ type: 'text', text: parsed.title }] },
1189
+ ...bodyContent,
1190
+ ];
1191
+ }
1192
+ const bodyDoc = { ...parsed.document, content: bodyContent };
1193
+ // Title is always label-suffixed: it doubles as the filename + sidebar name,
1194
+ // so it must stay unique vs the master (a raw duplicate title would collide).
1195
+ // The title CONTENT still rides along for title-bearing targets — they render
1196
+ // it as the headline and the user trims the suffix.
1197
+ const Label = tgtType.charAt(0).toUpperCase() + tgtType.slice(1);
1198
+ let newTitle = `${parsed.title} (${Label})`;
1199
+ let filePath = filePathForTitle(newTitle);
1200
+ if (existsSync(filePath)) {
1201
+ let counter = 2;
1202
+ while (existsSync(filePathForTitle(`${parsed.title} (${Label} ${counter})`)))
1203
+ counter++;
1204
+ newTitle = `${parsed.title} (${Label} ${counter})`;
1205
+ filePath = filePathForTitle(newTitle);
1206
+ }
1207
+ // Fresh metadata: target type scaffold + variant relationship only. Source
1208
+ // context objects are intentionally dropped (see header).
1209
+ const metadata = {
1210
+ title: newTitle,
1211
+ docId: generateNodeId(),
1212
+ ...(resolveTypeMeta(tgtType) || {}),
1213
+ masterDocId: opts.masterDocId,
1214
+ variantType: tgtType,
1215
+ };
1216
+ setActiveDocument(bodyDoc, newTitle, filePath, false, undefined, metadata);
1217
+ const { markdown } = tiptapToMarkdownChecked(bodyDoc, newTitle, metadata);
1218
+ ensureDataDir();
1219
+ atomicWriteFileSync(filePath, markdown);
1220
+ const newFilename = filePath.split(/[/\\]/).pop();
1221
+ return { document: getDocument(), title: getTitle(), filename: newFilename };
1222
+ }
1151
1223
  export function getActiveFilename() {
1152
1224
  const filePath = getFilePath();
1153
1225
  // For external docs, return the full path as the identifier
@@ -14,7 +14,7 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
14
14
  import { save, cancelDebouncedSave, load, getDocument, getTitle, getFilePath, getDocId, getMetadata, getStatus, updateDocument, setMetadata, applyTextEdits, isAgentLocked, getPendingDocInfo, getDocTagsByFilename, addDocTag, removeDocTag, markAllNodesAsPending, updatePendingCacheForActiveDoc, removePendingCacheEntry, clearAllCaches, stripPendingAttrs, stripPendingAttrsFromFile, setAutoAcceptOnFile, setSortRequestOnFile, clearSortRequestOnFile, bumpDocVersion, markAsAgentStub, extractText } from './state.js';
15
15
  import { syncPostHistory } from './post-sync.js';
16
16
  import { enrollManualPostForAutoplug } from './autoplug-enroll.js';
17
- import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
17
+ import { listDocuments, switchDocument, createDocument, deleteDocument, duplicateDocument, createVariant, reloadDocument, updateDocumentTitle, openFile, reorderDocs, searchDocuments, listArchivedDocuments, archiveDocument, unarchiveDocument, getActiveFilename, batchResolve, listPendingSorts } from './documents.js';
18
18
  import { writePromptDebug, isPromptDebugEnabled } from './prompt-debug.js';
19
19
  import { createWorkspaceRouter } from './workspace-routes.js';
20
20
  import { createLinkRouter } from './link-routes.js';
@@ -638,6 +638,25 @@ export async function startHttpServer(options = {}) {
638
638
  res.status(400).json({ error: err.message });
639
639
  }
640
640
  });
641
+ // Create variant: a retyped derivative nested under the master. Field-projection
642
+ // (body always; title folds into body on downcast; target type scaffolded) —
643
+ // NOT a verbatim clone (that's /duplicate). adr: docs/variants.md
644
+ app.post('/api/documents/variant', (req, res) => {
645
+ try {
646
+ const { filename, masterDocId, variantType } = req.body;
647
+ if (!filename || !masterDocId || !variantType) {
648
+ res.status(400).json({ error: 'filename, masterDocId, and variantType are required' });
649
+ return;
650
+ }
651
+ const result = createVariant(filename, { masterDocId, variantType });
652
+ broadcastDocumentSwitched(result.document, result.title, result.filename);
653
+ broadcastDocumentsChanged();
654
+ res.json(result);
655
+ }
656
+ catch (err) {
657
+ res.status(400).json({ error: err.message });
658
+ }
659
+ });
641
660
  app.post('/api/documents/batch-resolve', (req, res) => {
642
661
  try {
643
662
  const { filenames, action } = req.body;
@@ -14,6 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
14
14
  import { tiptapToBlocks } from './node-blocks.js';
15
15
  import { outline, peek, searchInDoc, truncateRead } from './peek-outline.js';
16
16
  import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
17
+ import { resolveTypeMeta } from './content-type-meta.js';
17
18
  import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions, listPendingSorts, sortFooter, buildSortInstructions, stagePendingTitle } from './documents.js';
18
19
  import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
19
20
  import { logger, generateRequestId, withRequestId } from './logger.js';
@@ -29,19 +30,6 @@ import { tiptapToMarkdown, splitFusedParagraphs } from './markdown.js';
29
30
  import { loadDocFromDisk } from './pending-overlay.js';
30
31
  import { getComments, getCommentCount, getGlobalCommentSummary, resolveComments } from './comments.js';
31
32
  import { readTasks, addTask, updateTask, removeTask } from './tasks.js';
32
- /** Map a content type string to its frontmatter metadata object. */
33
- function resolveTypeMeta(type, url) {
34
- switch (type) {
35
- case 'tweet': return { content_type: 'tweet', tweetContext: { mode: 'tweet' } };
36
- case 'reply': return { content_type: 'reply', tweetContext: { mode: 'reply', ...(url ? { url } : {}) } };
37
- case 'quote': return { content_type: 'quote', tweetContext: { mode: 'quote', ...(url ? { url } : {}) } };
38
- case 'article': return { content_type: 'article', articleContext: { active: true } };
39
- case 'linkedin': return { content_type: 'linkedin', linkedinContext: { active: true } };
40
- case 'newsletter': return { content_type: 'newsletter', newsletterContext: { active: true } };
41
- case 'blog': return { content_type: 'blog', blogContext: { active: true } };
42
- default: return undefined;
43
- }
44
- }
45
33
  /** Resolve a docId to a full document target. Fast path for active doc (zero I/O). */
46
34
  function resolveDocTarget(docId) {
47
35
  const filename = resolveDocId(docId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
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",