openwriter 0.40.2 → 0.40.3

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
  <!-- Fonts are self-hosted (bundled via @fontsource, imported in main.tsx →
11
11
  themes/vendored-fonts.css). No external font CDN: a local-first editor
12
12
  must work offline and behind content blockers. -->
13
- <script type="module" crossorigin src="/assets/index-Dxbv2n2m.js"></script>
13
+ <script type="module" crossorigin src="/assets/index-BjaX2BWN.js"></script>
14
14
  <link rel="stylesheet" crossorigin href="/assets/index-DiJrXBgV.css">
15
15
  </head>
16
16
  <body>
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Blog publishing routes — push blog content to GitHub via platform connection.
3
+ * Builds clean YAML frontmatter + markdown body, collects images, posts to platform.
4
+ */
5
+ import { Router } from 'express';
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import matter from 'gray-matter';
9
+ import { getDocument, getTitle, getMetadata, save, cancelDebouncedSave } from './state.js';
10
+ import { tiptapToMarkdown } from './markdown.js';
11
+ import { platformFetch, isAuthenticated } from './connections.js';
12
+ import { getDataDir } from './helpers.js';
13
+ export function createBlogRouter() {
14
+ const router = Router();
15
+ // Find active GitHub connection for blog publishing
16
+ router.get('/api/blog/connection', async (_req, res) => {
17
+ try {
18
+ if (!isAuthenticated()) {
19
+ res.json({ connection: null });
20
+ return;
21
+ }
22
+ const upstream = await platformFetch('/connections/unified');
23
+ if (!upstream.ok) {
24
+ res.json({ connection: null });
25
+ return;
26
+ }
27
+ const data = await upstream.json();
28
+ const ghConn = data.connections?.find((c) => c.provider === 'github' && c.status === 'active');
29
+ res.json({ connection: ghConn || null });
30
+ }
31
+ catch {
32
+ res.json({ connection: null });
33
+ }
34
+ });
35
+ // Publish blog post to GitHub via platform connection
36
+ router.post('/api/blog/publish', async (req, res) => {
37
+ try {
38
+ if (!isAuthenticated()) {
39
+ res.status(401).json({ error: 'Not authenticated' });
40
+ return;
41
+ }
42
+ // Flush current doc to disk
43
+ cancelDebouncedSave();
44
+ save();
45
+ const doc = getDocument();
46
+ const title = getTitle();
47
+ const metadata = getMetadata();
48
+ const blogCtx = metadata.blogContext;
49
+ if (!blogCtx?.active) {
50
+ res.status(400).json({ error: 'Not a blog document' });
51
+ return;
52
+ }
53
+ // Get clean markdown body (strip OpenWriter JSON frontmatter)
54
+ const fullMd = tiptapToMarkdown(doc, title, metadata);
55
+ const parsed = matter(fullMd);
56
+ let markdownBody = parsed.content.trim();
57
+ // Build clean blog YAML frontmatter — only include fields the target schema expects
58
+ const fm = {
59
+ title,
60
+ description: blogCtx.description || '',
61
+ date: blogCtx.date || new Date().toISOString().split('T')[0],
62
+ };
63
+ // Optional fields — only include if explicitly set
64
+ if (blogCtx.author)
65
+ fm.author = blogCtx.author;
66
+ if (blogCtx.layout)
67
+ fm.layout = blogCtx.layout;
68
+ if (blogCtx.category)
69
+ fm.category = blogCtx.category;
70
+ if (blogCtx.tags?.length)
71
+ fm.tags = blogCtx.tags;
72
+ if (blogCtx.draft)
73
+ fm.draft = true;
74
+ // Find GitHub connection
75
+ const connectionId = req.body.connectionId;
76
+ const connRes = await platformFetch('/connections/unified');
77
+ if (!connRes.ok) {
78
+ res.status(500).json({ error: 'Failed to fetch connections' });
79
+ return;
80
+ }
81
+ const connData = await connRes.json();
82
+ const ghConn = connectionId
83
+ ? connData.connections?.find((c) => c.id === connectionId)
84
+ : connData.connections?.find((c) => c.provider === 'github' && c.status === 'active');
85
+ if (!ghConn) {
86
+ res.status(400).json({ error: 'No GitHub connection found. Connect a GitHub repo first.' });
87
+ return;
88
+ }
89
+ // Fetch connection config to derive image web path
90
+ let imageWebPrefix = '/images';
91
+ try {
92
+ const cfgRes = await platformFetch(`/connections/${ghConn.id}/config`);
93
+ if (cfgRes.ok) {
94
+ const cfg = await cfgRes.json();
95
+ const imgDir = cfg.config?.imageDir || cfg.imageDir;
96
+ if (imgDir) {
97
+ imageWebPrefix = '/' + imgDir.replace(/^public\/?/, '');
98
+ }
99
+ }
100
+ }
101
+ catch { /* fall back to /images */ }
102
+ // Cover image — read as base64 for GitHub commit
103
+ let imageBase64;
104
+ let imageFilename;
105
+ if (blogCtx.coverImage) {
106
+ const imgPath = blogCtx.coverImage.replace(/^\/_images\//, '');
107
+ const fullImgPath = join(getDataDir(), '_images', imgPath);
108
+ if (existsSync(fullImgPath)) {
109
+ imageBase64 = readFileSync(fullImgPath).toString('base64');
110
+ imageFilename = imgPath;
111
+ fm.image = `${imageWebPrefix}/${imgPath}`;
112
+ }
113
+ }
114
+ // Collect inline images and rewrite paths in markdown
115
+ const inlineImages = [];
116
+ const imgRegex = /!\[([^\]]*)\]\(\/_images\/([^)]+)\)/g;
117
+ markdownBody = markdownBody.replace(imgRegex, (_match, alt, imgFile) => {
118
+ const fullImgPath = join(getDataDir(), '_images', imgFile);
119
+ if (existsSync(fullImgPath)) {
120
+ inlineImages.push({ filename: imgFile, base64: readFileSync(fullImgPath).toString('base64') });
121
+ }
122
+ return `![${alt}](${imageWebPrefix}/${imgFile})`;
123
+ });
124
+ // Assemble full markdown with YAML frontmatter
125
+ const yamlLines = Object.entries(fm).map(([k, v]) => {
126
+ if (Array.isArray(v))
127
+ return `${k}:\n${v.map(i => ` - ${JSON.stringify(i)}`).join('\n')}`;
128
+ if (typeof v === 'boolean')
129
+ return `${k}: ${v}`;
130
+ return `${k}: ${JSON.stringify(v)}`;
131
+ });
132
+ const fullMarkdown = `---\n${yamlLines.join('\n')}\n---\n\n${markdownBody}`;
133
+ // Build target filename from slug or title
134
+ const slug = blogCtx.slug || title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
135
+ const filename = `${slug}.md`;
136
+ // Post to platform — matches worker's expected payload:
137
+ // { markdown, filename, imageBase64?, imageFilename?, commitMessage? }
138
+ const upstream = await platformFetch(`/connections/${ghConn.id}/post`, {
139
+ method: 'POST',
140
+ body: JSON.stringify({
141
+ markdown: fullMarkdown,
142
+ filename,
143
+ ...(imageBase64 && imageFilename ? { imageBase64, imageFilename } : {}),
144
+ ...(inlineImages.length ? { images: inlineImages } : {}),
145
+ commitMessage: `Add blog post: ${title}`,
146
+ }),
147
+ });
148
+ const result = await upstream.json();
149
+ if (!upstream.ok) {
150
+ res.status(upstream.status).json(result);
151
+ return;
152
+ }
153
+ res.json({ success: true, ...result });
154
+ }
155
+ catch (err) {
156
+ res.status(500).json({ error: err.message });
157
+ }
158
+ });
159
+ return router;
160
+ }
@@ -913,6 +913,19 @@ export async function deleteDocument(filename) {
913
913
  throw new Error('Cannot delete the only document');
914
914
  }
915
915
  const isDeletingActive = targetPath === getFilePath();
916
+ // Capture the sidebar's flat order BEFORE the file is trashed, so a
917
+ // delete-of-the-active-doc can land the switch on the doc ADJACENT to the one
918
+ // removed (least-jarring — the view barely moves). The prior behavior switched
919
+ // to the globally newest-by-mtime doc, which flung the editor + filetree across
920
+ // the workspace to an unrelated doc. Only needed when deleting the active doc.
921
+ const orderedBefore = isDeletingActive
922
+ ? (() => { try {
923
+ return listDocuments().map((d) => d.filename);
924
+ }
925
+ catch {
926
+ return [];
927
+ } })()
928
+ : [];
916
929
  // Read docId BEFORE deleting the file so we can retire its overlay sidecar
917
930
  // in lockstep. The sidecar's lifecycle is bound to the docId's existence in
918
931
  // the workspace; delete retires the docId, archive does not.
@@ -933,16 +946,50 @@ export async function deleteDocument(filename) {
933
946
  if (docIdToRetire)
934
947
  deleteOverlay(docIdToRetire);
935
948
  if (isDeletingActive) {
936
- const remaining = readdirSync(getDataDir())
937
- .filter((f) => f.endsWith('.md'))
938
- .map((f) => ({ name: f, path: join(getDataDir(), f), mtime: statSync(join(getDataDir(), f)).mtimeMs }))
939
- .sort((a, b) => b.mtime - a.mtime);
940
- if (remaining.length > 0) {
941
- const next = remaining[0];
942
- const raw = readFileSync(next.path, 'utf-8');
949
+ // Prefer the doc adjacent to the deleted one in sidebar order — previous
950
+ // sibling first, then next. Falls back to newest-by-mtime only if the order
951
+ // lookup comes up empty (e.g. manifest missing), preserving old behavior.
952
+ let nextName = null;
953
+ const idx = orderedBefore.indexOf(filename);
954
+ if (idx >= 0) {
955
+ for (let i = idx - 1; i >= 0; i--) {
956
+ if (orderedBefore[i] !== filename) {
957
+ nextName = orderedBefore[i];
958
+ break;
959
+ }
960
+ }
961
+ if (!nextName) {
962
+ for (let i = idx + 1; i < orderedBefore.length; i++) {
963
+ if (orderedBefore[i] !== filename) {
964
+ nextName = orderedBefore[i];
965
+ break;
966
+ }
967
+ }
968
+ }
969
+ }
970
+ let nextPath = null;
971
+ if (nextName) {
972
+ const p = resolveDocPath(nextName);
973
+ if (existsSync(p))
974
+ nextPath = p;
975
+ }
976
+ if (!nextPath) {
977
+ const remaining = readdirSync(getDataDir())
978
+ .filter((f) => f.endsWith('.md'))
979
+ .map((f) => ({ name: f, path: join(getDataDir(), f), mtime: statSync(join(getDataDir(), f)).mtimeMs }))
980
+ .sort((a, b) => b.mtime - a.mtime);
981
+ if (remaining.length > 0) {
982
+ nextName = remaining[0].name;
983
+ nextPath = remaining[0].path;
984
+ }
985
+ }
986
+ if (nextPath && nextName) {
987
+ const nextBase = nextPath.split(/[/\\]/).pop() || nextName;
988
+ const raw = readFileSync(nextPath, 'utf-8');
943
989
  const parsed = markdownToTiptap(raw);
944
- setActiveDocument(parsed.document, parsed.title, next.path, next.name.startsWith(TEMP_PREFIX), new Date(next.mtime), parsed.metadata, undefined);
945
- return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: next.name } };
990
+ const mtime = statSync(nextPath).mtimeMs;
991
+ setActiveDocument(parsed.document, parsed.title, nextPath, nextBase.startsWith(TEMP_PREFIX), new Date(mtime), parsed.metadata, undefined);
992
+ return { switched: true, newDoc: { document: getDocument(), title: getTitle(), filename: nextName } };
946
993
  }
947
994
  }
948
995
  return { switched: false };
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Git sync module: all git/gh CLI interactions for GitHub sync.
3
+ * Uses child_process.execFile with shell:true (required on Windows).
4
+ */
5
+ import { execFile } from 'child_process';
6
+ import { existsSync, writeFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { getDataDir, readConfig, saveConfig } from './helpers.js';
9
+ import { save, cancelDebouncedSave } from './state.js';
10
+ const GITIGNORE_CONTENT = `config.json\n.versions/\n`;
11
+ const NETWORK_TIMEOUT = 30000;
12
+ let currentSyncState = 'unconfigured';
13
+ let lastError;
14
+ function exec(cmd, args, cwd, timeout = 10000) {
15
+ // Quote args with spaces so shell: true doesn't split them
16
+ const safeArgs = args.map(a => a.includes(' ') ? `"${a}"` : a);
17
+ return new Promise((resolve, reject) => {
18
+ execFile(cmd, safeArgs, { cwd, shell: true, timeout }, (err, stdout, stderr) => {
19
+ if (err)
20
+ reject(new Error(stderr?.trim() || err.message));
21
+ else
22
+ resolve(stdout.trim());
23
+ });
24
+ });
25
+ }
26
+ export async function isGitInstalled() {
27
+ try {
28
+ await exec('git', ['--version'], getDataDir());
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ export async function isGhInstalled() {
36
+ try {
37
+ await exec('gh', ['--version'], getDataDir());
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ export async function isGhAuthenticated() {
45
+ try {
46
+ await exec('gh', ['auth', 'status'], getDataDir());
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export function isGitRepo() {
54
+ return existsSync(join(getDataDir(), '.git'));
55
+ }
56
+ function ensureGitignore() {
57
+ const gitignorePath = join(getDataDir(), '.gitignore');
58
+ if (!existsSync(gitignorePath)) {
59
+ writeFileSync(gitignorePath, GITIGNORE_CONTENT, 'utf-8');
60
+ }
61
+ }
62
+ /** Count files that have changed since last commit (or all files if no commits yet). */
63
+ async function countPendingFiles() {
64
+ if (!isGitRepo())
65
+ return 0;
66
+ try {
67
+ // Check for any changes (staged + unstaged + untracked)
68
+ const status = await exec('git', ['status', '--porcelain'], getDataDir());
69
+ if (!status)
70
+ return 0;
71
+ return status.split('\n').filter(Boolean).length;
72
+ }
73
+ catch {
74
+ return 0;
75
+ }
76
+ }
77
+ /** Return the list of pending files with their status. */
78
+ export async function getPendingFiles() {
79
+ if (!isGitRepo())
80
+ return [];
81
+ try {
82
+ const output = await exec('git', ['status', '--porcelain'], getDataDir());
83
+ if (!output)
84
+ return [];
85
+ return output.split('\n').filter(Boolean).map(line => {
86
+ const code = line.substring(0, 2);
87
+ const file = line.substring(3);
88
+ let status = 'modified';
89
+ if (code.includes('?') || code.includes('A'))
90
+ status = 'added';
91
+ else if (code.includes('D'))
92
+ status = 'deleted';
93
+ else if (code.includes('R'))
94
+ status = 'renamed';
95
+ return { status, file };
96
+ });
97
+ }
98
+ catch {
99
+ return [];
100
+ }
101
+ }
102
+ export async function getSyncStatus() {
103
+ const config = readConfig();
104
+ if (!config.gitConfigured || !isGitRepo()) {
105
+ return { state: 'unconfigured' };
106
+ }
107
+ if (currentSyncState === 'syncing') {
108
+ return { state: 'syncing' };
109
+ }
110
+ if (currentSyncState === 'error' && lastError) {
111
+ return { state: 'error', error: lastError, lastSyncTime: config.lastSyncTime };
112
+ }
113
+ const pending = await countPendingFiles();
114
+ return {
115
+ state: pending > 0 ? 'pending' : 'synced',
116
+ pendingFiles: pending,
117
+ lastSyncTime: config.lastSyncTime,
118
+ };
119
+ }
120
+ export async function getCapabilities() {
121
+ const [git, gh] = await Promise.all([isGitInstalled(), isGhInstalled()]);
122
+ let ghAuth = false;
123
+ if (gh)
124
+ ghAuth = await isGhAuthenticated();
125
+ let remoteUrl;
126
+ if (isGitRepo()) {
127
+ try {
128
+ remoteUrl = await exec('git', ['remote', 'get-url', 'origin'], getDataDir());
129
+ }
130
+ catch { /* no remote */ }
131
+ }
132
+ return {
133
+ gitInstalled: git,
134
+ ghInstalled: gh,
135
+ ghAuthenticated: ghAuth,
136
+ existingRepo: isGitRepo(),
137
+ remoteUrl,
138
+ };
139
+ }
140
+ async function initRepo() {
141
+ if (!isGitRepo()) {
142
+ await exec('git', ['init'], getDataDir());
143
+ }
144
+ ensureGitignore();
145
+ // Ensure git user is configured (required for commits)
146
+ try {
147
+ await exec('git', ['config', 'user.name'], getDataDir());
148
+ }
149
+ catch {
150
+ await exec('git', ['config', 'user.name', 'OpenWriter'], getDataDir());
151
+ }
152
+ try {
153
+ await exec('git', ['config', 'user.email'], getDataDir());
154
+ }
155
+ catch {
156
+ await exec('git', ['config', 'user.email', 'openwriter@local'], getDataDir());
157
+ }
158
+ }
159
+ async function initialCommit() {
160
+ await exec('git', ['add', '-A'], getDataDir());
161
+ // Check if there's anything staged
162
+ const status = await exec('git', ['status', '--porcelain'], getDataDir());
163
+ if (!status)
164
+ return; // Nothing to commit
165
+ await exec('git', ['commit', '-m', 'Initial sync from OpenWriter'], getDataDir());
166
+ // Ensure branch is named 'main'
167
+ await exec('git', ['branch', '-M', 'main'], getDataDir());
168
+ }
169
+ export async function setupWithGh(repoName, isPrivate) {
170
+ await initRepo();
171
+ await initialCommit();
172
+ const visibility = isPrivate ? '--private' : '--public';
173
+ // Create repo without --push, then push separately for better error control
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
+ saveConfig({
177
+ gitConfigured: true,
178
+ repoName,
179
+ lastSyncTime: new Date().toISOString(),
180
+ });
181
+ currentSyncState = 'synced';
182
+ }
183
+ export async function setupWithPat(pat, repoName, isPrivate) {
184
+ // Create repo via GitHub REST API
185
+ const res = await fetch('https://api.github.com/user/repos', {
186
+ method: 'POST',
187
+ headers: {
188
+ Authorization: `Bearer ${pat}`,
189
+ 'Content-Type': 'application/json',
190
+ Accept: 'application/vnd.github+json',
191
+ },
192
+ body: JSON.stringify({ name: repoName, private: isPrivate, auto_init: false }),
193
+ });
194
+ if (!res.ok) {
195
+ const body = await res.json().catch(() => ({}));
196
+ throw new Error(body.message || `GitHub API error: ${res.status}`);
197
+ }
198
+ const repo = await res.json();
199
+ const remoteUrl = `https://${pat}@github.com/${repo.full_name}.git`;
200
+ await initRepo();
201
+ await initialCommit();
202
+ // Set remote
203
+ try {
204
+ await exec('git', ['remote', 'remove', 'origin'], getDataDir());
205
+ }
206
+ catch { /* no remote */ }
207
+ await exec('git', ['remote', 'add', 'origin', remoteUrl], getDataDir());
208
+ await exec('git', ['push', '-u', 'origin', 'main'], getDataDir(), NETWORK_TIMEOUT);
209
+ saveConfig({
210
+ gitConfigured: true,
211
+ gitPat: pat,
212
+ repoName,
213
+ gitRemote: repo.html_url,
214
+ lastSyncTime: new Date().toISOString(),
215
+ });
216
+ currentSyncState = 'synced';
217
+ }
218
+ export async function connectExisting(remoteUrl, pat) {
219
+ await initRepo();
220
+ await initialCommit();
221
+ // Embed PAT in URL if provided
222
+ let finalUrl = remoteUrl;
223
+ if (pat && remoteUrl.startsWith('https://')) {
224
+ finalUrl = remoteUrl.replace('https://', `https://${pat}@`);
225
+ }
226
+ try {
227
+ await exec('git', ['remote', 'remove', 'origin'], getDataDir());
228
+ }
229
+ catch { /* no remote */ }
230
+ await exec('git', ['remote', 'add', 'origin', finalUrl], getDataDir());
231
+ await exec('git', ['push', '-u', 'origin', 'main'], getDataDir(), NETWORK_TIMEOUT);
232
+ saveConfig({
233
+ gitConfigured: true,
234
+ gitPat: pat,
235
+ gitRemote: remoteUrl,
236
+ lastSyncTime: new Date().toISOString(),
237
+ });
238
+ currentSyncState = 'synced';
239
+ }
240
+ export async function pushSync(onStatus) {
241
+ currentSyncState = 'syncing';
242
+ lastError = undefined;
243
+ onStatus({ state: 'syncing' });
244
+ try {
245
+ // Flush current document to disk first (cancel debounce to ensure immediate write)
246
+ cancelDebouncedSave();
247
+ save();
248
+ ensureGitignore();
249
+ await exec('git', ['add', '-A'], getDataDir());
250
+ // Check if there's anything to commit
251
+ const status = await exec('git', ['status', '--porcelain'], getDataDir());
252
+ if (status) {
253
+ const timestamp = new Date().toLocaleString('en-US', {
254
+ month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
255
+ });
256
+ await exec('git', ['commit', '-m', `Sync: ${timestamp}`], getDataDir());
257
+ }
258
+ await exec('git', ['push'], getDataDir(), NETWORK_TIMEOUT);
259
+ const now = new Date().toISOString();
260
+ saveConfig({ lastSyncTime: now });
261
+ currentSyncState = 'synced';
262
+ const result = { state: 'synced', lastSyncTime: now, pendingFiles: 0 };
263
+ onStatus(result);
264
+ return result;
265
+ }
266
+ catch (err) {
267
+ currentSyncState = 'error';
268
+ lastError = err.message;
269
+ const result = { state: 'error', error: err.message };
270
+ onStatus(result);
271
+ return result;
272
+ }
273
+ }