openwriter 0.40.0 → 0.40.2
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-vmEPerKn.js → index-Dxbv2n2m.js} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/plugins/github/dist/blog-tools.d.ts +19 -0
- package/dist/plugins/github/dist/blog-tools.js +28 -9
- package/package.json +1 -1
- package/dist/server/blog-routes.js +0 -160
- package/dist/server/git-sync.js +0 -273
- package/dist/server/marks.js +0 -182
- package/dist/server/sync-routes.js +0 -75
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-Dxbv2n2m.js"></script>
|
|
14
14
|
<link rel="stylesheet" crossorigin href="/assets/index-DiJrXBgV.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
|
@@ -37,6 +37,25 @@ export declare function pathStyleOf(site: Pick<BlogSite, 'image_path_style'>): '
|
|
|
37
37
|
* relative → "images/og/x.png" absolute → "/images/og/x.png"
|
|
38
38
|
*/
|
|
39
39
|
export declare function imageRef(publicPrefix: string, file: string, style: 'relative' | 'absolute'): string;
|
|
40
|
+
/**
|
|
41
|
+
* Rewrite inline `/_images/` references in the post BODY to their published
|
|
42
|
+
* public paths, collecting the referenced filenames for copying.
|
|
43
|
+
*
|
|
44
|
+
* Body references ALWAYS emit an absolute (leading-slash) path, regardless of
|
|
45
|
+
* the site's `image_path_style`. That style contract exists for the
|
|
46
|
+
* FRONTMATTER cover only, whose value is rendered through the site's template
|
|
47
|
+
* — a relative-style site's layout prepends the slash itself (e.g. Astro
|
|
48
|
+
* `<img src={`/${image}`}>`). A raw markdown body image has no template: a
|
|
49
|
+
* slashless path is treated by Astro/Vite as an ESM import (`Rollup failed to
|
|
50
|
+
* resolve import "images/og/x.png"`) and red-builds the site, while a
|
|
51
|
+
* root-absolute path resolves against the public dir on every static
|
|
52
|
+
* framework. Do NOT collapse body refs back into the cover's path style.
|
|
53
|
+
* adr: adr/blog-image-contract.md
|
|
54
|
+
*/
|
|
55
|
+
export declare function rewriteBodyImages(bodyMd: string, publicPrefix: string): {
|
|
56
|
+
body: string;
|
|
57
|
+
imageRefs: Set<string>;
|
|
58
|
+
};
|
|
40
59
|
/**
|
|
41
60
|
* Resolve the deterministic cover filename from the site's naming template.
|
|
42
61
|
* `{slug}` → post slug
|
|
@@ -497,6 +497,29 @@ export function imageRef(publicPrefix, file, style) {
|
|
|
497
497
|
const rel = seg ? `${seg}/${file}` : file;
|
|
498
498
|
return style === 'absolute' ? `/${rel}` : rel;
|
|
499
499
|
}
|
|
500
|
+
/**
|
|
501
|
+
* Rewrite inline `/_images/` references in the post BODY to their published
|
|
502
|
+
* public paths, collecting the referenced filenames for copying.
|
|
503
|
+
*
|
|
504
|
+
* Body references ALWAYS emit an absolute (leading-slash) path, regardless of
|
|
505
|
+
* the site's `image_path_style`. That style contract exists for the
|
|
506
|
+
* FRONTMATTER cover only, whose value is rendered through the site's template
|
|
507
|
+
* — a relative-style site's layout prepends the slash itself (e.g. Astro
|
|
508
|
+
* `<img src={`/${image}`}>`). A raw markdown body image has no template: a
|
|
509
|
+
* slashless path is treated by Astro/Vite as an ESM import (`Rollup failed to
|
|
510
|
+
* resolve import "images/og/x.png"`) and red-builds the site, while a
|
|
511
|
+
* root-absolute path resolves against the public dir on every static
|
|
512
|
+
* framework. Do NOT collapse body refs back into the cover's path style.
|
|
513
|
+
* adr: adr/blog-image-contract.md
|
|
514
|
+
*/
|
|
515
|
+
export function rewriteBodyImages(bodyMd, publicPrefix) {
|
|
516
|
+
const imageRefs = new Set();
|
|
517
|
+
const body = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
|
|
518
|
+
imageRefs.add(fn);
|
|
519
|
+
return imageRef(publicPrefix, fn, 'absolute');
|
|
520
|
+
});
|
|
521
|
+
return { body, imageRefs };
|
|
522
|
+
}
|
|
500
523
|
/**
|
|
501
524
|
* Resolve the deterministic cover filename from the site's naming template.
|
|
502
525
|
* `{slug}` → post slug
|
|
@@ -989,18 +1012,14 @@ export function blogTools() {
|
|
|
989
1012
|
const slug = String(params.slug || blogCtx.slug || slugify(title));
|
|
990
1013
|
if (!slug)
|
|
991
1014
|
return { error: 'Could not derive a slug from the title.' };
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
1015
|
+
// Per-site image contract: `image_path_style` governs the FRONTMATTER
|
|
1016
|
+
// cover only. Inline BODY refs are always absolute — see
|
|
1017
|
+
// rewriteBodyImages. adr: adr/blog-image-contract.md
|
|
995
1018
|
const style = pathStyleOf(site);
|
|
996
1019
|
// Inline body images keep their source (hash) filenames — only the
|
|
997
|
-
// path
|
|
1020
|
+
// path is rewritten. Deterministic slug naming is scoped to the
|
|
998
1021
|
// COVER for now (inline naming is a noted follow-up in the ADR).
|
|
999
|
-
const imageRefs =
|
|
1000
|
-
const bodyRewritten = bodyMd.replace(/\/_images\/([^\s)"'<>]+)/g, (_m, fn) => {
|
|
1001
|
-
imageRefs.add(fn);
|
|
1002
|
-
return imageRef(site.image_public_prefix, fn, style);
|
|
1003
|
-
});
|
|
1022
|
+
const { body: bodyRewritten, imageRefs } = rewriteBodyImages(bodyMd, site.image_public_prefix);
|
|
1004
1023
|
// Cover image from blogContext → deterministic `og-{slug}.{ext}` name.
|
|
1005
1024
|
// Same doc + slug ⇒ same filename every republish (idempotent
|
|
1006
1025
|
// overwrite, no orphaned cover). Source extension is preserved.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openwriter",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.2",
|
|
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",
|
|
@@ -1,160 +0,0 @@
|
|
|
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 ``;
|
|
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
|
-
}
|
package/dist/server/git-sync.js
DELETED
|
@@ -1,273 +0,0 @@
|
|
|
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
|
-
}
|