openwriter 0.1.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,109 @@
1
+ /**
2
+ * HTML template for document export.
3
+ * Returns a complete HTML document with embedded print-friendly CSS.
4
+ * Used by both .html export and PDF print preview.
5
+ */
6
+ export function buildExportHtml(title, bodyHtml) {
7
+ return `<!DOCTYPE html>
8
+ <html lang="en">
9
+ <head>
10
+ <meta charset="UTF-8">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
12
+ <title>${escapeHtml(title)}</title>
13
+ <style>
14
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
15
+
16
+ body {
17
+ font-family: Georgia, 'Times New Roman', serif;
18
+ font-size: 16px;
19
+ line-height: 1.7;
20
+ color: #1a1a1a;
21
+ max-width: 700px;
22
+ margin: 0 auto;
23
+ padding: 40px 20px;
24
+ }
25
+
26
+ h1, h2, h3, h4, h5, h6 {
27
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
28
+ line-height: 1.3;
29
+ margin-top: 1.5em;
30
+ margin-bottom: 0.5em;
31
+ color: #111;
32
+ }
33
+ h1 { font-size: 2em; border-bottom: 1px solid #ddd; padding-bottom: 0.3em; }
34
+ h2 { font-size: 1.5em; }
35
+ h3 { font-size: 1.25em; }
36
+
37
+ p { margin-bottom: 1em; }
38
+
39
+ a { color: #2563eb; text-decoration: underline; }
40
+
41
+ blockquote {
42
+ border-left: 3px solid #ccc;
43
+ margin: 1em 0;
44
+ padding: 0.5em 1em;
45
+ color: #555;
46
+ }
47
+
48
+ pre {
49
+ background: #f5f5f5;
50
+ border: 1px solid #ddd;
51
+ border-radius: 4px;
52
+ padding: 12px 16px;
53
+ overflow-x: auto;
54
+ margin: 1em 0;
55
+ font-size: 14px;
56
+ line-height: 1.5;
57
+ }
58
+ code {
59
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
60
+ font-size: 0.9em;
61
+ background: #f0f0f0;
62
+ padding: 2px 4px;
63
+ border-radius: 3px;
64
+ }
65
+ pre code { background: none; padding: 0; border-radius: 0; }
66
+
67
+ ul, ol { margin: 1em 0; padding-left: 2em; }
68
+ li { margin-bottom: 0.3em; }
69
+
70
+ table {
71
+ border-collapse: collapse;
72
+ width: 100%;
73
+ margin: 1em 0;
74
+ }
75
+ th, td {
76
+ border: 1px solid #ccc;
77
+ padding: 8px 12px;
78
+ text-align: left;
79
+ }
80
+ th { background: #f5f5f5; font-weight: 600; }
81
+
82
+ hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
83
+
84
+ img { max-width: 100%; height: auto; }
85
+
86
+ ins { text-decoration: underline; }
87
+ mark { background: #fff3a3; padding: 1px 2px; }
88
+ sub { font-size: 0.75em; }
89
+ sup { font-size: 0.75em; }
90
+
91
+ @media print {
92
+ body { padding: 0; max-width: none; }
93
+ a { color: inherit; text-decoration: none; }
94
+ pre { border-color: #ccc; }
95
+ }
96
+ </style>
97
+ </head>
98
+ <body>
99
+ ${bodyHtml}
100
+ </body>
101
+ </html>`;
102
+ }
103
+ function escapeHtml(str) {
104
+ return str
105
+ .replace(/&/g, '&amp;')
106
+ .replace(/</g, '&lt;')
107
+ .replace(/>/g, '&gt;')
108
+ .replace(/"/g, '&quot;');
109
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Export routes: GET /api/export?format=md|html|docx|txt|pdf
3
+ * Converts the current document to the requested format.
4
+ */
5
+ import { Router } from 'express';
6
+ import MarkdownIt from 'markdown-it';
7
+ import markdownItIns from 'markdown-it-ins';
8
+ import markdownItMark from 'markdown-it-mark';
9
+ import markdownItSub from 'markdown-it-sub';
10
+ import markdownItSup from 'markdown-it-sup';
11
+ import { tiptapToMarkdown } from './markdown.js';
12
+ import { getDocument, getTitle, getPlainText, getMetadata } from './state.js';
13
+ import { buildExportHtml } from './export-html-template.js';
14
+ // markdown-it instance matching markdown-parse.ts configuration
15
+ const md = new MarkdownIt({ linkify: false });
16
+ md.enable('strikethrough');
17
+ md.use(markdownItIns);
18
+ md.use(markdownItMark);
19
+ md.use(markdownItSub);
20
+ md.use(markdownItSup);
21
+ /** Strip YAML frontmatter (---\n...\n---\n\n) from markdown output. */
22
+ function stripFrontmatter(markdown) {
23
+ const match = markdown.match(/^---\n[\s\S]*?\n---\n\n/);
24
+ return match ? markdown.slice(match[0].length) : markdown;
25
+ }
26
+ function sanitizeFilename(title) {
27
+ return title.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').slice(0, 100);
28
+ }
29
+ export function createExportRouter() {
30
+ const router = Router();
31
+ router.get('/api/export', async (req, res) => {
32
+ const format = (req.query.format || '').toLowerCase();
33
+ const title = getTitle();
34
+ const safeName = sanitizeFilename(title);
35
+ try {
36
+ switch (format) {
37
+ case 'md': {
38
+ const raw = tiptapToMarkdown(getDocument(), title, getMetadata());
39
+ const clean = stripFrontmatter(raw);
40
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
41
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}.md"`);
42
+ res.send(clean);
43
+ break;
44
+ }
45
+ case 'html': {
46
+ const raw = tiptapToMarkdown(getDocument(), title, getMetadata());
47
+ const clean = stripFrontmatter(raw);
48
+ const bodyHtml = md.render(clean);
49
+ const fullHtml = buildExportHtml(title, bodyHtml);
50
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
51
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}.html"`);
52
+ res.send(fullHtml);
53
+ break;
54
+ }
55
+ case 'docx': {
56
+ const raw = tiptapToMarkdown(getDocument(), title, getMetadata());
57
+ const clean = stripFrontmatter(raw);
58
+ const bodyHtml = md.render(clean);
59
+ const { default: HtmlToDocx } = await import('@turbodocx/html-to-docx');
60
+ const docxBuffer = await HtmlToDocx(bodyHtml, undefined, {
61
+ title,
62
+ margins: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
63
+ });
64
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
65
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}.docx"`);
66
+ res.send(Buffer.from(docxBuffer));
67
+ break;
68
+ }
69
+ case 'txt': {
70
+ const text = getPlainText();
71
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
72
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}.txt"`);
73
+ res.send(text);
74
+ break;
75
+ }
76
+ case 'pdf': {
77
+ const raw = tiptapToMarkdown(getDocument(), title, getMetadata());
78
+ const clean = stripFrontmatter(raw);
79
+ const bodyHtml = md.render(clean);
80
+ const fullHtml = buildExportHtml(title, bodyHtml);
81
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
82
+ res.setHeader('Content-Disposition', 'inline');
83
+ res.send(fullHtml);
84
+ break;
85
+ }
86
+ default:
87
+ res.status(400).json({ error: `Unknown format: ${format}. Use md, html, docx, txt, or pdf.` });
88
+ }
89
+ }
90
+ catch (err) {
91
+ console.error('[Export] Error:', err.message);
92
+ res.status(500).json({ error: 'Export failed' });
93
+ }
94
+ });
95
+ return router;
96
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Google Doc → OpenWriter import.
3
+ * Accepts raw Google Doc JSON, converts to markdown.
4
+ * Single-section docs → one .md file.
5
+ * Multi-section docs (multiple HEADING_1) → chapter files + book manifest.
6
+ */
7
+ import { writeFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { DATA_DIR, ensureDataDir, sanitizeFilename } from './helpers.js';
10
+ import { createWorkspace, addDoc, addContainerToWorkspace } from './workspaces.js';
11
+ // ============================================================================
12
+ // GOOGLE DOC → MARKDOWN CONVERSION
13
+ // ============================================================================
14
+ function textRunToMarkdown(element) {
15
+ if (!element.textRun)
16
+ return '';
17
+ const text = element.textRun.content;
18
+ if (!text || text === '\n')
19
+ return '';
20
+ const style = element.textRun.textStyle || {};
21
+ let result = text.replace(/\n$/, '');
22
+ // Trim inner whitespace before wrapping — GDocs often has bold runs ending
23
+ // with spaces like "Territory " which produces broken "**Territory **"
24
+ if (style.bold && style.italic) {
25
+ result = `***${result.trim()}***`;
26
+ }
27
+ else if (style.bold) {
28
+ result = `**${result.trim()}**`;
29
+ }
30
+ else if (style.italic) {
31
+ result = `*${result.trim()}*`;
32
+ }
33
+ if (style.link?.url) {
34
+ result = `[${result}](${style.link.url})`;
35
+ }
36
+ return result;
37
+ }
38
+ function textRunToPlainText(element) {
39
+ if (!element.textRun)
40
+ return '';
41
+ const text = element.textRun.content;
42
+ if (!text || text === '\n')
43
+ return '';
44
+ return text.replace(/\n$/, '');
45
+ }
46
+ function paragraphToMarkdown(para) {
47
+ const style = para.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
48
+ const elements = para.elements || [];
49
+ const isHeading = style?.startsWith('HEADING_');
50
+ // Headings: strip bold/italic — GDocs scatters random bold across heading runs
51
+ // which produces broken markdown like **Territory & Masculinity (v0.**2**.0)**
52
+ let text = elements.map(isHeading ? textRunToPlainText : textRunToMarkdown).join('');
53
+ if (!text.trim())
54
+ return '';
55
+ if (style === 'HEADING_1')
56
+ return `# ${text.trim()}`;
57
+ if (style === 'HEADING_2')
58
+ return `## ${text.trim()}`;
59
+ if (style === 'HEADING_3')
60
+ return `### ${text.trim()}`;
61
+ if (style === 'HEADING_4')
62
+ return `#### ${text.trim()}`;
63
+ if (para.bullet) {
64
+ const level = para.bullet.nestingLevel || 0;
65
+ const indent = ' '.repeat(level);
66
+ return `${indent}- ${text.trim()}`;
67
+ }
68
+ return text.trim();
69
+ }
70
+ function structuralElementToMarkdown(element) {
71
+ if (element.paragraph) {
72
+ return paragraphToMarkdown(element.paragraph);
73
+ }
74
+ if (element.table) {
75
+ const rows = [];
76
+ for (const row of (element.table.tableRows || [])) {
77
+ const cells = [];
78
+ for (const cell of (row.tableCells || [])) {
79
+ const cellText = (cell.content || [])
80
+ .map((el) => el.paragraph ? paragraphToMarkdown(el.paragraph) : '')
81
+ .filter(Boolean)
82
+ .join(' ');
83
+ cells.push(cellText);
84
+ }
85
+ rows.push('| ' + cells.join(' | ') + ' |');
86
+ }
87
+ if (rows.length > 1) {
88
+ const headerSep = '| ' + rows[0].split('|').slice(1, -1).map(() => '---').join(' | ') + ' |';
89
+ rows.splice(1, 0, headerSep);
90
+ }
91
+ return rows.join('\n');
92
+ }
93
+ return '';
94
+ }
95
+ // ============================================================================
96
+ // SECTION SPLITTING
97
+ // ============================================================================
98
+ function splitIntoSections(doc) {
99
+ const elements = doc.body?.content || [];
100
+ const sections = [];
101
+ let current = null;
102
+ for (const element of elements) {
103
+ if (!element.paragraph) {
104
+ if (current)
105
+ current.elements.push(element);
106
+ continue;
107
+ }
108
+ const style = element.paragraph.paragraphStyle?.namedStyleType;
109
+ if (style === 'HEADING_1') {
110
+ const title = (element.paragraph.elements || [])
111
+ .map((el) => el.textRun?.content || '')
112
+ .join('')
113
+ .trim();
114
+ current = { title, elements: [element] };
115
+ sections.push(current);
116
+ }
117
+ else {
118
+ if (!current) {
119
+ current = { title: 'Preamble', elements: [element] };
120
+ sections.push(current);
121
+ }
122
+ else {
123
+ current.elements.push(element);
124
+ }
125
+ }
126
+ }
127
+ return sections;
128
+ }
129
+ function sectionToMarkdown(section) {
130
+ const lines = [];
131
+ for (const element of section.elements) {
132
+ const md = structuralElementToMarkdown(element);
133
+ if (md)
134
+ lines.push(md);
135
+ }
136
+ return lines.join('\n\n');
137
+ }
138
+ function elementsToMarkdown(elements) {
139
+ const lines = [];
140
+ for (const element of elements) {
141
+ const md = structuralElementToMarkdown(element);
142
+ if (md)
143
+ lines.push(md);
144
+ }
145
+ return lines.join('\n\n');
146
+ }
147
+ function writeDocFile(title, markdownBody) {
148
+ const filename = `${sanitizeFilename(title).substring(0, 200)}.md`;
149
+ const filepath = join(DATA_DIR, filename);
150
+ const metadata = { title };
151
+ const content = `---\n${JSON.stringify(metadata)}\n---\n\n${markdownBody}`;
152
+ writeFileSync(filepath, content, 'utf-8');
153
+ const wordCount = markdownBody.trim() ? markdownBody.trim().split(/\s+/).length : 0;
154
+ return { filename, wordCount };
155
+ }
156
+ // ============================================================================
157
+ // IMPORT
158
+ // ============================================================================
159
+ /**
160
+ * Import a Google Doc JSON into OpenWriter.
161
+ * - Single-section doc → one .md file
162
+ * - Multi-section doc (2+ HEADING_1) → chapter files + book manifest
163
+ */
164
+ export function importGoogleDoc(gdocJson, title) {
165
+ ensureDataDir();
166
+ const docTitle = title || gdocJson.title || 'Imported Document';
167
+ const sections = splitIntoSections(gdocJson);
168
+ if (sections.length === 0) {
169
+ throw new Error('No content found in Google Doc');
170
+ }
171
+ // Single section (or no H1 splits) → import as one document
172
+ if (sections.length <= 1) {
173
+ const allElements = gdocJson.body?.content || [];
174
+ const markdown = elementsToMarkdown(allElements);
175
+ const file = writeDocFile(docTitle, markdown);
176
+ return {
177
+ title: docTitle,
178
+ mode: 'single',
179
+ files: [{ title: docTitle, filename: file.filename, wordCount: file.wordCount }],
180
+ };
181
+ }
182
+ // Multiple sections → split into chapter files + workspace with ordered container
183
+ const fileResults = [];
184
+ for (const section of sections) {
185
+ const markdown = sectionToMarkdown(section);
186
+ const file = writeDocFile(section.title, markdown);
187
+ fileResults.push({ title: section.title, filename: file.filename, wordCount: file.wordCount });
188
+ }
189
+ const wsInfo = createWorkspace({ title: docTitle });
190
+ const { containerId } = addContainerToWorkspace(wsInfo.filename, null, 'Chapters');
191
+ for (const fileResult of fileResults) {
192
+ addDoc(wsInfo.filename, containerId, fileResult.filename, fileResult.title);
193
+ }
194
+ return {
195
+ title: docTitle,
196
+ mode: 'workspace',
197
+ workspaceFilename: wsInfo.filename,
198
+ files: fileResults,
199
+ };
200
+ }
@@ -0,0 +1,272 @@
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 { DATA_DIR, readConfig, saveConfig } from './helpers.js';
9
+ import { save } 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'], DATA_DIR);
29
+ return true;
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ export async function isGhInstalled() {
36
+ try {
37
+ await exec('gh', ['--version'], DATA_DIR);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ export async function isGhAuthenticated() {
45
+ try {
46
+ await exec('gh', ['auth', 'status'], DATA_DIR);
47
+ return true;
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ export function isGitRepo() {
54
+ return existsSync(join(DATA_DIR, '.git'));
55
+ }
56
+ function ensureGitignore() {
57
+ const gitignorePath = join(DATA_DIR, '.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'], DATA_DIR);
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'], DATA_DIR);
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'], DATA_DIR);
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'], DATA_DIR);
143
+ }
144
+ ensureGitignore();
145
+ // Ensure git user is configured (required for commits)
146
+ try {
147
+ await exec('git', ['config', 'user.name'], DATA_DIR);
148
+ }
149
+ catch {
150
+ await exec('git', ['config', 'user.name', 'OpenWriter'], DATA_DIR);
151
+ }
152
+ try {
153
+ await exec('git', ['config', 'user.email'], DATA_DIR);
154
+ }
155
+ catch {
156
+ await exec('git', ['config', 'user.email', 'openwriter@local'], DATA_DIR);
157
+ }
158
+ }
159
+ async function initialCommit() {
160
+ await exec('git', ['add', '-A'], DATA_DIR);
161
+ // Check if there's anything staged
162
+ const status = await exec('git', ['status', '--porcelain'], DATA_DIR);
163
+ if (!status)
164
+ return; // Nothing to commit
165
+ await exec('git', ['commit', '-m', 'Initial sync from OpenWriter'], DATA_DIR);
166
+ // Ensure branch is named 'main'
167
+ await exec('git', ['branch', '-M', 'main'], DATA_DIR);
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'], DATA_DIR, NETWORK_TIMEOUT);
175
+ await exec('git', ['push', '-u', 'origin', 'main'], DATA_DIR, 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'], DATA_DIR);
205
+ }
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);
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'], DATA_DIR);
228
+ }
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);
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
246
+ save();
247
+ ensureGitignore();
248
+ await exec('git', ['add', '-A'], DATA_DIR);
249
+ // Check if there's anything to commit
250
+ const status = await exec('git', ['status', '--porcelain'], DATA_DIR);
251
+ if (status) {
252
+ const timestamp = new Date().toLocaleString('en-US', {
253
+ month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',
254
+ });
255
+ await exec('git', ['commit', '-m', `Sync: ${timestamp}`], DATA_DIR);
256
+ }
257
+ await exec('git', ['push'], DATA_DIR, NETWORK_TIMEOUT);
258
+ const now = new Date().toISOString();
259
+ saveConfig({ lastSyncTime: now });
260
+ currentSyncState = 'synced';
261
+ const result = { state: 'synced', lastSyncTime: now, pendingFiles: 0 };
262
+ onStatus(result);
263
+ return result;
264
+ }
265
+ catch (err) {
266
+ currentSyncState = 'error';
267
+ lastError = err.message;
268
+ const result = { state: 'error', error: err.message };
269
+ onStatus(result);
270
+ return result;
271
+ }
272
+ }