openwriter 0.5.4 → 0.6.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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Platform connection proxy — all connections live on the platform (Neon),
3
+ * local app proxies through the publish API.
4
+ */
5
+ import { readConfig, getActiveProfile } from './helpers.js';
6
+ const DEFAULT_API_URL = 'https://publish.openwriter.io';
7
+ /** Get API key and URL from plugin config */
8
+ function getPublishConfig() {
9
+ const config = readConfig();
10
+ const publishConfig = config.plugins?.['@openwriter/plugin-publish']?.config || {};
11
+ return {
12
+ apiKey: publishConfig['api-key'] || '',
13
+ apiUrl: publishConfig['api-url'] || DEFAULT_API_URL,
14
+ };
15
+ }
16
+ /** Authenticated fetch to the platform API */
17
+ export async function platformFetch(path, options = {}) {
18
+ const { apiKey, apiUrl } = getPublishConfig();
19
+ const profile = getActiveProfile();
20
+ if (!apiKey) {
21
+ throw new Error('Not authenticated. Use the publish plugin to log in first.');
22
+ }
23
+ const headers = {
24
+ 'Content-Type': 'application/json',
25
+ Authorization: `Bearer ${apiKey}`,
26
+ 'X-Profile': profile,
27
+ ...(options.headers || {}),
28
+ };
29
+ return fetch(`${apiUrl}${path}`, { ...options, headers });
30
+ }
31
+ /** Check if the user is authenticated with the platform */
32
+ export function isAuthenticated() {
33
+ const { apiKey } = getPublishConfig();
34
+ return !!apiKey;
35
+ }
@@ -10,23 +10,23 @@ import trash from 'trash';
10
10
  import { tiptapToMarkdown, markdownToTiptap } from './markdown.js';
11
11
  import { parseMarkdownContent } from './compact.js';
12
12
  import { getDocument, getTitle, getFilePath, getIsTemp, getMetadata, save, cancelDebouncedSave, setActiveDocument, registerExternalDoc, unregisterExternalDoc, getExternalDocs, cacheActiveDocument, getCachedDocument, invalidateDocCache, removePendingCacheEntry, } from './state.js';
13
- import { DATA_DIR, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, resolveDocPath, isExternalDoc, atomicWriteFileSync } from './helpers.js';
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';
16
16
  import { renameMark } from './marks.js';
17
17
  import { getDocId as getActiveDocId } from './state.js';
18
- const DOC_ORDER_FILE = join(DATA_DIR, '_doc-order.json');
19
- /** Scan files for matching docId. Checks active doc first (free), then DATA_DIR, then external docs. */
18
+ function getDocOrderFile() { return join(getDataDir(), '_doc-order.json'); }
19
+ /** Scan files for matching docId. Checks active doc first (free), then getDataDir(), then external docs. */
20
20
  export function filenameByDocId(docId) {
21
21
  // Fast path: check active document (no disk read)
22
22
  if (getActiveDocId() === docId) {
23
23
  return getActiveFilename();
24
24
  }
25
- // Scan DATA_DIR files
25
+ // Scan getDataDir() files
26
26
  ensureDataDir();
27
- for (const f of readdirSync(DATA_DIR).filter(f => f.endsWith('.md'))) {
27
+ for (const f of readdirSync(getDataDir()).filter(f => f.endsWith('.md'))) {
28
28
  try {
29
- const raw = readFileSync(join(DATA_DIR, f), 'utf-8');
29
+ const raw = readFileSync(join(getDataDir(), f), 'utf-8');
30
30
  const { data } = matter(raw);
31
31
  if (data.docId === docId)
32
32
  return f;
@@ -56,9 +56,9 @@ export function resolveDocId(docId) {
56
56
  }
57
57
  function readDocOrder() {
58
58
  try {
59
- if (!existsSync(DOC_ORDER_FILE))
59
+ if (!existsSync(getDocOrderFile()))
60
60
  return [];
61
- return JSON.parse(readFileSync(DOC_ORDER_FILE, 'utf-8'));
61
+ return JSON.parse(readFileSync(getDocOrderFile(), 'utf-8'));
62
62
  }
63
63
  catch {
64
64
  return [];
@@ -66,7 +66,7 @@ function readDocOrder() {
66
66
  }
67
67
  function writeDocOrder(order) {
68
68
  ensureDataDir();
69
- writeFileSync(DOC_ORDER_FILE, JSON.stringify(order, null, 2), 'utf-8');
69
+ writeFileSync(getDocOrderFile(), JSON.stringify(order, null, 2), 'utf-8');
70
70
  }
71
71
  export function reorderDocs(orderedFilenames) {
72
72
  writeDocOrder(orderedFilenames);
@@ -74,10 +74,10 @@ export function reorderDocs(orderedFilenames) {
74
74
  export function listDocuments() {
75
75
  ensureDataDir();
76
76
  const currentPath = getFilePath();
77
- const files = readdirSync(DATA_DIR)
77
+ const files = readdirSync(getDataDir())
78
78
  .filter((f) => f.endsWith('.md'))
79
79
  .map((f) => {
80
- const fullPath = join(DATA_DIR, f);
80
+ const fullPath = join(getDataDir(), f);
81
81
  try {
82
82
  const stat = statSync(fullPath);
83
83
  const raw = readFileSync(fullPath, 'utf-8');
@@ -100,6 +100,7 @@ export function listDocuments() {
100
100
  wordCount,
101
101
  isActive: fullPath === currentPath,
102
102
  ...(data.docId ? { docId: data.docId } : {}),
103
+ ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : {}),
103
104
  };
104
105
  }
105
106
  catch {
@@ -128,6 +129,7 @@ export function listDocuments() {
128
129
  wordCount,
129
130
  isActive: extPath === currentPath,
130
131
  ...(data.docId ? { docId: data.docId } : {}),
132
+ ...(data.newsletterContext?.lastSend?.sentAt ? { lastSent: data.newsletterContext.lastSend.sentAt } : {}),
131
133
  });
132
134
  }
133
135
  catch { /* skip unreadable external files */ }
@@ -157,10 +159,10 @@ export function listDocuments() {
157
159
  // ============================================================================
158
160
  export function listArchivedDocuments() {
159
161
  ensureDataDir();
160
- const files = readdirSync(DATA_DIR)
162
+ const files = readdirSync(getDataDir())
161
163
  .filter((f) => f.endsWith('.md'))
162
164
  .map((f) => {
163
- const fullPath = join(DATA_DIR, f);
165
+ const fullPath = join(getDataDir(), f);
164
166
  try {
165
167
  const stat = statSync(fullPath);
166
168
  const raw = readFileSync(fullPath, 'utf-8');
@@ -207,10 +209,10 @@ export function archiveDocument(filename) {
207
209
  const isArchivingActive = targetPath === getFilePath();
208
210
  if (isArchivingActive) {
209
211
  // Switch to most recent remaining doc
210
- const remaining = readdirSync(DATA_DIR)
212
+ const remaining = readdirSync(getDataDir())
211
213
  .filter((f) => f.endsWith('.md') && f !== filename)
212
214
  .map((f) => {
213
- const fullPath = join(DATA_DIR, f);
215
+ const fullPath = join(getDataDir(), f);
214
216
  try {
215
217
  const stat = statSync(fullPath);
216
218
  const raw = readFileSync(fullPath, 'utf-8');
@@ -255,9 +257,9 @@ export function searchDocuments(query, includeArchived = false) {
255
257
  // Collect all files (same pattern as listDocuments)
256
258
  ensureDataDir();
257
259
  const allFiles = [];
258
- for (const f of readdirSync(DATA_DIR).filter(f => f.endsWith('.md'))) {
260
+ for (const f of readdirSync(getDataDir()).filter(f => f.endsWith('.md'))) {
259
261
  try {
260
- const fullPath = join(DATA_DIR, f);
262
+ const fullPath = join(getDataDir(), f);
261
263
  const mtime = statSync(fullPath).mtime;
262
264
  const raw = readFileSync(fullPath, 'utf-8');
263
265
  allFiles.push({ filename: f, path: fullPath, raw, mtime });
@@ -333,6 +335,10 @@ export function searchDocuments(query, includeArchived = false) {
333
335
  return results;
334
336
  }
335
337
  export function switchDocument(filename) {
338
+ // No-op if already on this document — avoids save/reload cycle that can clear editor content
339
+ if (filename === getActiveFilename()) {
340
+ return { document: getDocument(), title: getTitle(), filename };
341
+ }
336
342
  // Cancel any pending debounced save, then save current doc immediately.
337
343
  cancelDebouncedSave();
338
344
  save();
@@ -429,7 +435,7 @@ export function createDocument(title, content, path) {
429
435
  * so the user's editor isn't hijacked during agent content generation.
430
436
  * The file is written with agentCreated: true in frontmatter.
431
437
  */
432
- export function createDocumentFile(title, path) {
438
+ export function createDocumentFile(title, path, extraMeta) {
433
439
  const docTitle = title || 'Untitled';
434
440
  let filePath;
435
441
  let filename;
@@ -458,7 +464,7 @@ export function createDocumentFile(title, path) {
458
464
  filename = filePath.split(/[/\\]/).pop();
459
465
  }
460
466
  const newDoc = { type: 'doc', content: [{ type: 'paragraph', content: [] }] };
461
- const metadata = { title: docTitle, docId: generateNodeId(), agentCreated: true };
467
+ const metadata = { title: docTitle, docId: generateNodeId(), agentCreated: true, ...extraMeta };
462
468
  const markdown = tiptapToMarkdown(newDoc, docTitle, metadata);
463
469
  ensureDataDir();
464
470
  atomicWriteFileSync(filePath, markdown);
@@ -473,7 +479,7 @@ export async function deleteDocument(filename) {
473
479
  if (isExternalDoc(filename)) {
474
480
  unregisterExternalDoc(targetPath);
475
481
  }
476
- const allDocs = readdirSync(DATA_DIR).filter((f) => f.endsWith('.md'));
482
+ const allDocs = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
477
483
  if (allDocs.length <= 1) {
478
484
  throw new Error('Cannot delete the only document');
479
485
  }
@@ -482,9 +488,9 @@ export async function deleteDocument(filename) {
482
488
  await trash(targetPath);
483
489
  }
484
490
  if (isDeletingActive) {
485
- const remaining = readdirSync(DATA_DIR)
491
+ const remaining = readdirSync(getDataDir())
486
492
  .filter((f) => f.endsWith('.md'))
487
- .map((f) => ({ name: f, path: join(DATA_DIR, f), mtime: statSync(join(DATA_DIR, f)).mtimeMs }))
493
+ .map((f) => ({ name: f, path: join(getDataDir(), f), mtime: statSync(join(getDataDir(), f)).mtimeMs }))
488
494
  .sort((a, b) => b.mtime - a.mtime);
489
495
  if (remaining.length > 0) {
490
496
  const next = remaining[0];
@@ -537,7 +543,7 @@ export function openFile(fullPath) {
537
543
  save();
538
544
  // Cache current doc before switching
539
545
  cacheActiveDocument();
540
- // Register as external if not in DATA_DIR
546
+ // Register as external if not in getDataDir()
541
547
  if (isExternalDoc(fullPath)) {
542
548
  registerExternalDoc(fullPath);
543
549
  }
@@ -554,7 +560,7 @@ export function openFile(fullPath) {
554
560
  ensureDocId(parsed.metadata);
555
561
  const baseName = fullPath.split(/[/\\]/).pop() || '';
556
562
  setActiveDocument(parsed.document, parsed.title, fullPath, baseName.startsWith(TEMP_PREFIX), mtime, parsed.metadata);
557
- // Use full path as filename for external docs, basename for DATA_DIR docs
563
+ // Use full path as filename for external docs, basename for getDataDir() docs
558
564
  const filename = isExternalDoc(fullPath) ? fullPath : baseName;
559
565
  return { document: getDocument(), title: getTitle(), filename };
560
566
  }
@@ -42,7 +42,7 @@ export function buildExportHtml(title, bodyHtml) {
42
42
  border-left: 3px solid #ccc;
43
43
  margin: 1em 0;
44
44
  padding: 0.5em 1em;
45
- color: #555;
45
+ color: #666;
46
46
  }
47
47
 
48
48
  pre {
@@ -12,7 +12,7 @@ import { tiptapToMarkdown } from './markdown.js';
12
12
  import { getDocument, getTitle, getPlainText, getMetadata } from './state.js';
13
13
  import { buildExportHtml } from './export-html-template.js';
14
14
  // markdown-it instance matching markdown-parse.ts configuration
15
- const md = new MarkdownIt({ linkify: false });
15
+ const md = new MarkdownIt({ linkify: false, html: true });
16
16
  md.enable('strikethrough');
17
17
  md.use(markdownItIns);
18
18
  md.use(markdownItMark);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { writeFileSync } from 'fs';
8
8
  import { join } from 'path';
9
- import { DATA_DIR, ensureDataDir, sanitizeFilename } from './helpers.js';
9
+ import { getDataDir, ensureDataDir, sanitizeFilename } from './helpers.js';
10
10
  import { createWorkspace, addDoc, addContainerToWorkspace } from './workspaces.js';
11
11
  // ============================================================================
12
12
  // GOOGLE DOC → MARKDOWN CONVERSION
@@ -146,7 +146,7 @@ function elementsToMarkdown(elements) {
146
146
  }
147
147
  function writeDocFile(title, markdownBody) {
148
148
  const filename = `${sanitizeFilename(title).substring(0, 200)}.md`;
149
- const filepath = join(DATA_DIR, filename);
149
+ const filepath = join(getDataDir(), filename);
150
150
  const metadata = { title };
151
151
  const content = `---\n${JSON.stringify(metadata)}\n---\n\n${markdownBody}`;
152
152
  writeFileSync(filepath, content, 'utf-8');
@@ -5,7 +5,7 @@
5
5
  import { execFile } from 'child_process';
6
6
  import { existsSync, writeFileSync } from 'fs';
7
7
  import { join } from 'path';
8
- import { DATA_DIR, readConfig, saveConfig } from './helpers.js';
8
+ import { getDataDir, readConfig, saveConfig } from './helpers.js';
9
9
  import { save, cancelDebouncedSave } from './state.js';
10
10
  const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
11
11
  const NETWORK_TIMEOUT = 30000;
@@ -25,7 +25,7 @@ function exec(cmd, args, cwd, timeout = 10000) {
25
25
  }
26
26
  export async function isGitInstalled() {
27
27
  try {
28
- await exec('git', ['--version'], DATA_DIR);
28
+ await exec('git', ['--version'], getDataDir());
29
29
  return true;
30
30
  }
31
31
  catch {
@@ -34,7 +34,7 @@ export async function isGitInstalled() {
34
34
  }
35
35
  export async function isGhInstalled() {
36
36
  try {
37
- await exec('gh', ['--version'], DATA_DIR);
37
+ await exec('gh', ['--version'], getDataDir());
38
38
  return true;
39
39
  }
40
40
  catch {
@@ -43,7 +43,7 @@ export async function isGhInstalled() {
43
43
  }
44
44
  export async function isGhAuthenticated() {
45
45
  try {
46
- await exec('gh', ['auth', 'status'], DATA_DIR);
46
+ await exec('gh', ['auth', 'status'], getDataDir());
47
47
  return true;
48
48
  }
49
49
  catch {
@@ -51,10 +51,10 @@ export async function isGhAuthenticated() {
51
51
  }
52
52
  }
53
53
  export function isGitRepo() {
54
- return existsSync(join(DATA_DIR, '.git'));
54
+ return existsSync(join(getDataDir(), '.git'));
55
55
  }
56
56
  function ensureGitignore() {
57
- const gitignorePath = join(DATA_DIR, '.gitignore');
57
+ const gitignorePath = join(getDataDir(), '.gitignore');
58
58
  if (!existsSync(gitignorePath)) {
59
59
  writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
60
60
  }
@@ -65,7 +65,7 @@ async function countPendingFiles() {
65
65
  return 0;
66
66
  try {
67
67
  // Check for any changes (staged + unstaged + untracked)
68
- const status = await exec('git', ['status', '--porcelain'], DATA_DIR);
68
+ const status = await exec('git', ['status', '--porcelain'], getDataDir());
69
69
  if (!status)
70
70
  return 0;
71
71
  return status.split('\n').filter(Boolean).length;
@@ -79,7 +79,7 @@ export async function getPendingFiles() {
79
79
  if (!isGitRepo())
80
80
  return [];
81
81
  try {
82
- const output = await exec('git', ['status', '--porcelain'], DATA_DIR);
82
+ const output = await exec('git', ['status', '--porcelain'], getDataDir());
83
83
  if (!output)
84
84
  return [];
85
85
  return output.split('\n').filter(Boolean).map(line => {
@@ -125,7 +125,7 @@ export async function getCapabilities() {
125
125
  let remoteUrl;
126
126
  if (isGitRepo()) {
127
127
  try {
128
- remoteUrl = await exec('git', ['remote', 'get-url', 'origin'], DATA_DIR);
128
+ remoteUrl = await exec('git', ['remote', 'get-url', 'origin'], getDataDir());
129
129
  }
130
130
  catch { /* no remote */ }
131
131
  }
@@ -139,40 +139,40 @@ export async function getCapabilities() {
139
139
  }
140
140
  async function initRepo() {
141
141
  if (!isGitRepo()) {
142
- await exec('git', ['init'], DATA_DIR);
142
+ await exec('git', ['init'], getDataDir());
143
143
  }
144
144
  ensureGitignore();
145
145
  // Ensure git user is configured (required for commits)
146
146
  try {
147
- await exec('git', ['config', 'user.name'], DATA_DIR);
147
+ await exec('git', ['config', 'user.name'], getDataDir());
148
148
  }
149
149
  catch {
150
- await exec('git', ['config', 'user.name', 'OpenWriter'], DATA_DIR);
150
+ await exec('git', ['config', 'user.name', 'OpenWriter'], getDataDir());
151
151
  }
152
152
  try {
153
- await exec('git', ['config', 'user.email'], DATA_DIR);
153
+ await exec('git', ['config', 'user.email'], getDataDir());
154
154
  }
155
155
  catch {
156
- await exec('git', ['config', 'user.email', 'openwriter@local'], DATA_DIR);
156
+ await exec('git', ['config', 'user.email', 'openwriter@local'], getDataDir());
157
157
  }
158
158
  }
159
159
  async function initialCommit() {
160
- await exec('git', ['add', '-A'], DATA_DIR);
160
+ await exec('git', ['add', '-A'], getDataDir());
161
161
  // Check if there's anything staged
162
- const status = await exec('git', ['status', '--porcelain'], DATA_DIR);
162
+ const status = await exec('git', ['status', '--porcelain'], getDataDir());
163
163
  if (!status)
164
164
  return; // Nothing to commit
165
- await exec('git', ['commit', '-m', 'Initial sync from OpenWriter'], DATA_DIR);
165
+ await exec('git', ['commit', '-m', 'Initial sync from OpenWriter'], getDataDir());
166
166
  // Ensure branch is named 'main'
167
- await exec('git', ['branch', '-M', 'main'], DATA_DIR);
167
+ await exec('git', ['branch', '-M', 'main'], getDataDir());
168
168
  }
169
169
  export async function setupWithGh(repoName, isPrivate) {
170
170
  await initRepo();
171
171
  await initialCommit();
172
172
  const visibility = isPrivate ? '--private' : '--public';
173
173
  // Create repo without --push, then push separately for better error control
174
- await exec('gh', ['repo', 'create', repoName, visibility, '--source=.', '--remote=origin'], DATA_DIR, NETWORK_TIMEOUT);
175
- await exec('git', ['push', '-u', 'origin', 'main'], DATA_DIR, NETWORK_TIMEOUT);
174
+ await exec('gh', ['repo', 'create', repoName, visibility, '--source=.', '--remote=origin'], getDataDir(), NETWORK_TIMEOUT);
175
+ await exec('git', ['push', '-u', 'origin', 'main'], getDataDir(), NETWORK_TIMEOUT);
176
176
  saveConfig({
177
177
  gitConfigured: true,
178
178
  repoName,
@@ -201,11 +201,11 @@ export async function setupWithPat(pat, repoName, isPrivate) {
201
201
  await initialCommit();
202
202
  // Set remote
203
203
  try {
204
- await exec('git', ['remote', 'remove', 'origin'], DATA_DIR);
204
+ await exec('git', ['remote', 'remove', 'origin'], getDataDir());
205
205
  }
206
206
  catch { /* no remote */ }
207
- await exec('git', ['remote', 'add', 'origin', remoteUrl], DATA_DIR);
208
- await exec('git', ['push', '-u', 'origin', 'main'], DATA_DIR, NETWORK_TIMEOUT);
207
+ await exec('git', ['remote', 'add', 'origin', remoteUrl], getDataDir());
208
+ await exec('git', ['push', '-u', 'origin', 'main'], getDataDir(), NETWORK_TIMEOUT);
209
209
  saveConfig({
210
210
  gitConfigured: true,
211
211
  gitPat: pat,
@@ -224,11 +224,11 @@ export async function connectExisting(remoteUrl, pat) {
224
224
  finalUrl = remoteUrl.replace('https://', `https://${pat}@`);
225
225
  }
226
226
  try {
227
- await exec('git', ['remote', 'remove', 'origin'], DATA_DIR);
227
+ await exec('git', ['remote', 'remove', 'origin'], getDataDir());
228
228
  }
229
229
  catch { /* no remote */ }
230
- await exec('git', ['remote', 'add', 'origin', finalUrl], DATA_DIR);
231
- await exec('git', ['push', '-u', 'origin', 'main'], DATA_DIR, NETWORK_TIMEOUT);
230
+ await exec('git', ['remote', 'add', 'origin', finalUrl], getDataDir());
231
+ await exec('git', ['push', '-u', 'origin', 'main'], getDataDir(), NETWORK_TIMEOUT);
232
232
  saveConfig({
233
233
  gitConfigured: true,
234
234
  gitPat: pat,
@@ -246,16 +246,16 @@ export async function pushSync(onStatus) {
246
246
  cancelDebouncedSave();
247
247
  save();
248
248
  ensureGitignore();
249
- await exec('git', ['add', '-A'], DATA_DIR);
249
+ await exec('git', ['add', '-A'], getDataDir());
250
250
  // Check if there's anything to commit
251
- const status = await exec('git', ['status', '--porcelain'], DATA_DIR);
251
+ const status = await exec('git', ['status', '--porcelain'], getDataDir());
252
252
  if (status) {
253
253
  const timestamp = new Date().toLocaleString('en-US', {
254
254
  month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
255
255
  });
256
- await exec('git', ['commit', '-m', `Sync: ${timestamp}`], DATA_DIR);
256
+ await exec('git', ['commit', '-m', `Sync: ${timestamp}`], getDataDir());
257
257
  }
258
- await exec('git', ['push'], DATA_DIR, NETWORK_TIMEOUT);
258
+ await exec('git', ['push'], getDataDir(), NETWORK_TIMEOUT);
259
259
  const now = new Date().toISOString();
260
260
  saveConfig({ lastSyncTime: now });
261
261
  currentSyncState = 'synced';