create-confluence-sync 1.0.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.
package/src/hook.js ADDED
@@ -0,0 +1,118 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createApiClient } from './api.js';
4
+ import { loadTree, saveTree } from './tree.js';
5
+ import { pull, push, detectHidden } from './sync.js';
6
+ import {
7
+ getChangedFiles,
8
+ getDeletedFiles,
9
+ getCurrentBranch,
10
+ createBranch,
11
+ switchBranch,
12
+ commitAll,
13
+ mergeBranch,
14
+ deleteBranch,
15
+ } from './git.js';
16
+
17
+ const PREFIX = '[confluence-sync]';
18
+
19
+ function log(message) {
20
+ console.log(`${PREFIX} ${message}`);
21
+ }
22
+
23
+ function loadConfig(projectRoot) {
24
+ const configPath = path.join(projectRoot, '.confluence', 'config.json');
25
+ if (!fs.existsSync(configPath)) {
26
+ throw new Error(`Config not found: ${configPath}`);
27
+ }
28
+ const raw = fs.readFileSync(configPath, 'utf-8');
29
+ return JSON.parse(raw);
30
+ }
31
+
32
+ async function main() {
33
+ // Guard: prevent recursion
34
+ if (process.env.CONFLUENCE_SYNC_RUNNING === '1') {
35
+ return;
36
+ }
37
+ process.env.CONFLUENCE_SYNC_RUNNING = '1';
38
+
39
+ // Normalize projectRoot (Windows backslashes)
40
+ const rawRoot = process.argv[2];
41
+ if (!rawRoot) {
42
+ console.error(`${PREFIX} Error: projectRoot argument is required`);
43
+ process.exit(1);
44
+ }
45
+ const projectRoot = path.resolve(rawRoot.replace(/\\/g, '/'));
46
+
47
+ // Load config
48
+ const config = loadConfig(projectRoot);
49
+
50
+ // Determine changed .xhtml files from the last commit
51
+ const changedFiles = getChangedFiles(projectRoot);
52
+ if (changedFiles.length === 0) {
53
+ return; // commit does not touch documentation
54
+ }
55
+
56
+ log('Starting sync...');
57
+ log(`Changed files: ${changedFiles.length}`);
58
+
59
+ // Load tree
60
+ const tree = loadTree(projectRoot);
61
+
62
+ // Create API client
63
+ const apiClient = createApiClient(config);
64
+
65
+ // Detect hidden (only check files deleted in this commit)
66
+ const deletedFiles = getDeletedFiles(projectRoot);
67
+ const hiddenCount = detectHidden(tree, projectRoot, deletedFiles);
68
+ if (hiddenCount > 0) {
69
+ log(`Hidden: ${hiddenCount} pages marked as hidden`);
70
+ }
71
+
72
+ // --- Pull (Confluence -> Local) ---
73
+ const savedBranch = getCurrentBranch(projectRoot);
74
+
75
+ // Clean up stale branch from a previous failed merge
76
+ try {
77
+ deleteBranch(projectRoot, 'confluence/pull');
78
+ } catch {
79
+ // Branch doesn't exist — normal case
80
+ }
81
+
82
+ createBranch(projectRoot, 'confluence/pull');
83
+
84
+ const pullResult = await pull(apiClient, tree, projectRoot, config.space);
85
+ saveTree(projectRoot, tree);
86
+ commitAll(projectRoot, `${PREFIX} pull from Confluence`);
87
+
88
+ log(`Pull: ${pullResult.created} created, ${pullResult.updated} updated, ${pullResult.deleted} deleted`);
89
+
90
+ // Switch back to working branch and merge
91
+ switchBranch(projectRoot, savedBranch);
92
+
93
+ try {
94
+ mergeBranch(projectRoot, 'confluence/pull');
95
+ } catch (err) {
96
+ if (err.message.includes('conflict') || err.message.includes('CONFLICT')) {
97
+ log('Merge conflict detected! Resolve conflicts manually, then commit.');
98
+ log('The confluence/pull branch has been kept for reference.');
99
+ process.exit(1);
100
+ }
101
+ throw err;
102
+ }
103
+
104
+ deleteBranch(projectRoot, 'confluence/pull');
105
+
106
+ // --- Push (Local -> Confluence) ---
107
+ const pushResult = await push(apiClient, tree, projectRoot, config.space, changedFiles);
108
+ saveTree(projectRoot, tree);
109
+ commitAll(projectRoot, `${PREFIX} push to Confluence + update tree`);
110
+
111
+ log(`Push: ${pushResult.created} created, ${pushResult.updated} updated`);
112
+ log('Sync complete!');
113
+ }
114
+
115
+ main().catch((err) => {
116
+ console.error(`${PREFIX} Error: ${err.message}`);
117
+ process.exit(1);
118
+ });
package/src/sync.js ADDED
@@ -0,0 +1,278 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ addPage,
5
+ hidePage,
6
+ getPageByPath,
7
+ getChangedPages,
8
+ getDeletedLocalFiles,
9
+ buildPath,
10
+ pathToParentId,
11
+ sanitizeName,
12
+ } from './tree.js';
13
+
14
+ export async function pull(apiClient, tree, projectRoot, spaceKey) {
15
+ const remotePages = await apiClient.getPageTree(spaceKey);
16
+ const { created, updated, deleted } = getChangedPages(tree, remotePages);
17
+
18
+ for (const pageId of created) {
19
+ const filePath = buildPath(tree, pageId, remotePages, spaceKey);
20
+ if (!filePath) continue;
21
+
22
+ const content = await apiClient.getPageContent(pageId);
23
+ const absolutePath = path.resolve(projectRoot, filePath);
24
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
25
+ await fs.writeFile(absolutePath, content.body, 'utf-8');
26
+ console.log(`Created: ${filePath}`);
27
+
28
+ const attachments = await apiClient.getPageAttachments(pageId);
29
+
30
+ addPage(tree, pageId, {
31
+ title: remotePages[pageId].title,
32
+ path: filePath,
33
+ parentId: remotePages[pageId].parentId,
34
+ version: content.version,
35
+ attachments: attachments.map((a) => ({
36
+ id: a.id,
37
+ title: a.title,
38
+ version: a.version,
39
+ downloaded: false,
40
+ })),
41
+ });
42
+ }
43
+
44
+ for (const pageId of updated) {
45
+ const page = tree.pages[pageId];
46
+ const newPath = buildPath(tree, pageId, remotePages, spaceKey);
47
+ const filePath = newPath || page.path;
48
+ const content = await apiClient.getPageContent(pageId);
49
+
50
+ if (newPath && newPath !== page.path) {
51
+ const oldAbsolute = path.resolve(projectRoot, page.path);
52
+ try { await fs.unlink(oldAbsolute); } catch {}
53
+ }
54
+
55
+ const absolutePath = path.resolve(projectRoot, filePath);
56
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
57
+ await fs.writeFile(absolutePath, content.body, 'utf-8');
58
+ console.log(`Updated: ${filePath}`);
59
+
60
+ const attachments = await apiClient.getPageAttachments(pageId);
61
+
62
+ addPage(tree, pageId, {
63
+ title: remotePages[pageId].title,
64
+ path: filePath,
65
+ parentId: remotePages[pageId].parentId,
66
+ version: content.version,
67
+ attachments: attachments.map((a) => ({
68
+ id: a.id,
69
+ title: a.title,
70
+ version: a.version,
71
+ downloaded: false,
72
+ })),
73
+ });
74
+ }
75
+
76
+ for (const pageId of deleted) {
77
+ const page = tree.pages[pageId];
78
+ if (!page) continue;
79
+
80
+ const absolutePath = path.resolve(projectRoot, page.path);
81
+ try { await fs.unlink(absolutePath); } catch {}
82
+
83
+ let dir = path.dirname(absolutePath);
84
+ while (dir !== projectRoot && dir !== path.dirname(dir)) {
85
+ try {
86
+ await fs.rmdir(dir);
87
+ dir = path.dirname(dir);
88
+ } catch {
89
+ break;
90
+ }
91
+ }
92
+
93
+ console.log(` Deleted: ${page.path}`);
94
+ delete tree.pages[pageId];
95
+ }
96
+
97
+ // Restore: download pages that are in tree, not hidden, but missing from disk
98
+ for (const [pageId, page] of Object.entries(tree.pages)) {
99
+ if (page.hidden || !remotePages[pageId]) continue;
100
+ const absolutePath = path.resolve(projectRoot, page.path);
101
+ try {
102
+ await fs.access(absolutePath);
103
+ } catch {
104
+ // File missing on disk — download it
105
+ try {
106
+ const content = await apiClient.getPageContent(pageId);
107
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
108
+ await fs.writeFile(absolutePath, content.body, 'utf-8');
109
+ page.version = content.version;
110
+ page.lastSynced = new Date().toISOString();
111
+ console.log(`Restored: ${page.path}`);
112
+ } catch (err) {
113
+ console.error(`Error restoring ${page.title}: ${err.message}`);
114
+ }
115
+ }
116
+ }
117
+
118
+ // Tree cleanup: remove hidden pages that no longer exist in Confluence
119
+ for (const pageId of Object.keys(tree.pages)) {
120
+ if (tree.pages[pageId].hidden && !remotePages[pageId]) {
121
+ console.log(`Cleaned up: ${tree.pages[pageId].title} (removed from Confluence)`);
122
+ delete tree.pages[pageId];
123
+ }
124
+ }
125
+
126
+ // Tree cleanup: detect renamed pages and move files on disk
127
+ let renamed = 0;
128
+ for (const pageId of Object.keys(tree.pages)) {
129
+ const page = tree.pages[pageId];
130
+ if (page.hidden || !remotePages[pageId]) continue;
131
+
132
+ const remoteTitle = remotePages[pageId].title;
133
+ if (page.title === remoteTitle) continue;
134
+
135
+ const oldPath = page.path;
136
+ const newFsName = sanitizeName(remoteTitle);
137
+ const newPath = buildPath(tree, pageId, remotePages, tree.space);
138
+
139
+ if (!newPath || newPath === oldPath) continue;
140
+
141
+ const oldAbsolute = path.resolve(projectRoot, oldPath);
142
+ const newAbsolute = path.resolve(projectRoot, newPath);
143
+
144
+ try {
145
+ await fs.mkdir(path.dirname(newAbsolute), { recursive: true });
146
+ await fs.rename(oldAbsolute, newAbsolute);
147
+ console.log(`Renamed: ${oldPath} → ${newPath}`);
148
+ } catch (err) {
149
+ console.log(`Rename failed (${oldPath} → ${newPath}): ${err.message}`);
150
+ continue;
151
+ }
152
+
153
+ page.title = remoteTitle;
154
+ page.fsName = newFsName;
155
+ page.path = newPath;
156
+ renamed++;
157
+ }
158
+
159
+ tree.lastSync = new Date().toISOString();
160
+
161
+ return { created: created.length, updated: updated.length, deleted: deleted.length, renamed };
162
+ }
163
+
164
+ export async function push(apiClient, tree, projectRoot, spaceKey, changedFiles) {
165
+ let created = 0;
166
+ let updated = 0;
167
+
168
+ for (const filePath of changedFiles) {
169
+ const normalized = filePath.replace(/\\/g, '/');
170
+ const absolutePath = path.resolve(projectRoot, normalized);
171
+ const body = await fs.readFile(absolutePath, 'utf-8');
172
+ const existing = getPageByPath(tree, normalized);
173
+
174
+ if (existing) {
175
+ const result = await apiClient.updatePage(
176
+ existing.pageId, existing.title, body, existing.version
177
+ );
178
+ console.log(`Pushed update: ${normalized}`);
179
+
180
+ tree.pages[existing.pageId].version = result.version;
181
+ tree.pages[existing.pageId].lastSynced = new Date().toISOString();
182
+ updated++;
183
+ } else {
184
+ const parentId = pathToParentId(tree, normalized);
185
+ const parts = normalized.split('/');
186
+ const fileName = parts[parts.length - 1];
187
+ const title = fileName.replace(/\.xhtml$/, '');
188
+
189
+ const result = await apiClient.createPage(spaceKey, parentId, title, body);
190
+ console.log(`Pushed new: ${normalized}`);
191
+
192
+ addPage(tree, result.id, {
193
+ title,
194
+ path: normalized,
195
+ parentId,
196
+ version: result.version,
197
+ });
198
+ created++;
199
+ }
200
+ }
201
+
202
+ tree.lastSync = new Date().toISOString();
203
+
204
+ return { created, updated };
205
+ }
206
+
207
+ export function detectHidden(tree, projectRoot, deletedFiles) {
208
+ let count = 0;
209
+
210
+ if (deletedFiles && deletedFiles.length > 0) {
211
+ // Fast path: only check files we know were deleted in the commit
212
+ for (const filePath of deletedFiles) {
213
+ const normalized = filePath.replace(/\\/g, '/');
214
+ const page = getPageByPath(tree, normalized);
215
+ if (page && !page.hidden) {
216
+ console.log(`Hidden: ${normalized}`);
217
+ hidePage(tree, page.pageId);
218
+ count++;
219
+ }
220
+ }
221
+ } else {
222
+ // Fallback: check all pages (used when no git diff available)
223
+ const deletedIds = getDeletedLocalFiles(tree, projectRoot);
224
+ for (const pageId of deletedIds) {
225
+ console.log(`Hidden: ${tree.pages[pageId].path}`);
226
+ hidePage(tree, pageId);
227
+ count++;
228
+ }
229
+ }
230
+
231
+ return count;
232
+ }
233
+
234
+ export async function initialPull(apiClient, tree, projectRoot, spaceKey) {
235
+ const remotePages = await apiClient.getPageTree(spaceKey);
236
+ let count = 0;
237
+
238
+ for (const pageId of Object.keys(remotePages)) {
239
+ const filePath = buildPath(tree, pageId, remotePages, spaceKey);
240
+ if (!filePath) continue;
241
+
242
+ try {
243
+ const content = await apiClient.getPageContent(pageId);
244
+ const absolutePath = path.resolve(projectRoot, filePath);
245
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
246
+ await fs.writeFile(absolutePath, content.body, 'utf-8');
247
+ console.log(`Synced: ${filePath}`);
248
+
249
+ let attachments = [];
250
+ try {
251
+ attachments = await apiClient.getPageAttachments(pageId);
252
+ } catch {
253
+ // some pages may not support attachments
254
+ }
255
+
256
+ addPage(tree, pageId, {
257
+ title: remotePages[pageId].title,
258
+ path: filePath,
259
+ parentId: remotePages[pageId].parentId,
260
+ version: content.version,
261
+ attachments: attachments.map((a) => ({
262
+ id: a.id,
263
+ title: a.title,
264
+ version: a.version,
265
+ downloaded: false,
266
+ })),
267
+ });
268
+
269
+ count++;
270
+ } catch (err) {
271
+ console.error(`Error syncing page ${pageId} (${remotePages[pageId]?.title}): ${err.message}`);
272
+ }
273
+ }
274
+
275
+ tree.lastSync = new Date().toISOString();
276
+
277
+ return count;
278
+ }
package/src/tree.js ADDED
@@ -0,0 +1,171 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const TREE_PATH = path.join('.confluence', 'tree.json');
5
+
6
+ function emptyTree() {
7
+ return { space: '', baseUrl: '', lastSync: null, pages: {} };
8
+ }
9
+
10
+ export function loadTree(projectRoot) {
11
+ const filePath = path.join(projectRoot, TREE_PATH);
12
+ if (!fs.existsSync(filePath)) return emptyTree();
13
+ const raw = fs.readFileSync(filePath, 'utf-8');
14
+ return JSON.parse(raw);
15
+ }
16
+
17
+ export function saveTree(projectRoot, tree) {
18
+ const dirPath = path.join(projectRoot, '.confluence');
19
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
20
+ const filePath = path.join(projectRoot, TREE_PATH);
21
+ fs.writeFileSync(filePath, JSON.stringify(tree, null, 2), 'utf-8');
22
+ }
23
+
24
+ export function addPage(tree, pageId, data) {
25
+ const id = String(pageId);
26
+ const existing = tree.pages[id] || {};
27
+ const fsName = data.fsName ?? sanitizeName(data.title);
28
+ tree.pages[id] = {
29
+ title: data.title,
30
+ fsName,
31
+ path: data.path,
32
+ parentId: data.parentId ?? existing.parentId ?? null,
33
+ version: data.version ?? existing.version ?? 1,
34
+ lastSynced: new Date().toISOString(),
35
+ hidden: data.hidden ?? existing.hidden ?? false,
36
+ attachments: data.attachments ?? existing.attachments ?? [],
37
+ };
38
+ }
39
+
40
+ export function hidePage(tree, pageId) {
41
+ const id = String(pageId);
42
+ if (tree.pages[id]) tree.pages[id].hidden = true;
43
+ }
44
+
45
+ export function unhidePage(tree, pageId) {
46
+ const id = String(pageId);
47
+ if (tree.pages[id]) tree.pages[id].hidden = false;
48
+ }
49
+
50
+ export function getPageByPath(tree, filePath) {
51
+ const normalized = filePath.replace(/\\/g, '/');
52
+ for (const [pageId, pageData] of Object.entries(tree.pages)) {
53
+ if (pageData.path === normalized) {
54
+ return { pageId, ...pageData };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function getPageById(tree, pageId) {
61
+ const id = String(pageId);
62
+ return tree.pages[id] ?? null;
63
+ }
64
+
65
+ export function getChangedPages(tree, remotePages) {
66
+ const updated = [];
67
+ const created = [];
68
+ const deleted = [];
69
+
70
+ for (const [pageId, remote] of Object.entries(remotePages)) {
71
+ const local = tree.pages[pageId];
72
+ if (!local) {
73
+ created.push(pageId);
74
+ } else if (local.hidden) {
75
+ continue;
76
+ } else if (remote.version > local.version) {
77
+ updated.push(pageId);
78
+ }
79
+ }
80
+
81
+ for (const [pageId, local] of Object.entries(tree.pages)) {
82
+ if (!remotePages[pageId] && !local.hidden) {
83
+ deleted.push(pageId);
84
+ }
85
+ }
86
+
87
+ return { updated, created, deleted };
88
+ }
89
+
90
+ export function getNewLocalFiles(tree, projectRoot, docsDir) {
91
+ const knownPaths = new Set();
92
+ for (const page of Object.values(tree.pages)) {
93
+ knownPaths.add(page.path);
94
+ }
95
+
96
+ const results = [];
97
+ const resolvedDocsDir = path.resolve(projectRoot, docsDir);
98
+
99
+ function scan(dir) {
100
+ if (!fs.existsSync(dir)) return;
101
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
102
+ for (const entry of entries) {
103
+ const fullPath = path.join(dir, entry.name);
104
+ if (entry.isDirectory()) {
105
+ scan(fullPath);
106
+ } else if (entry.isFile() && entry.name.endsWith('.xhtml')) {
107
+ const relativePath = path.relative(projectRoot, fullPath).replace(/\\/g, '/');
108
+ if (!knownPaths.has(relativePath)) {
109
+ results.push(relativePath);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ scan(resolvedDocsDir);
116
+ return results;
117
+ }
118
+
119
+ export function getDeletedLocalFiles(tree, projectRoot) {
120
+ const result = [];
121
+ for (const [pageId, page] of Object.entries(tree.pages)) {
122
+ if (page.hidden) continue;
123
+ const absolutePath = path.resolve(projectRoot, page.path);
124
+ if (!fs.existsSync(absolutePath)) {
125
+ result.push(pageId);
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+
131
+ export function sanitizeName(name) {
132
+ return name
133
+ .replace(/[<>:"/\\|?*]/g, '_')
134
+ .replace(/\s+$/, '')
135
+ .replace(/\.+$/, '')
136
+ .substring(0, 100);
137
+ }
138
+
139
+ export function buildPath(tree, pageId, remotePages, spaceKey) {
140
+ const segments = [];
141
+ let currentId = pageId;
142
+
143
+ while (currentId) {
144
+ const remote = remotePages[currentId];
145
+ if (!remote) break;
146
+ segments.unshift(sanitizeName(remote.title));
147
+ currentId = remote.parentId;
148
+ }
149
+
150
+ const title = remotePages[pageId]?.title;
151
+ if (!title) return null;
152
+
153
+ const safeName = sanitizeName(title);
154
+ const parts = ['docs', spaceKey, ...segments, `${safeName}.xhtml`];
155
+ return parts.join('/');
156
+ }
157
+
158
+ export function pathToParentId(tree, filePath) {
159
+ const normalized = filePath.replace(/\\/g, '/');
160
+ const parts = normalized.split('/');
161
+ const parentDir = parts.slice(0, -2).join('/');
162
+
163
+ for (const [pageId, page] of Object.entries(tree.pages)) {
164
+ const pageDir = page.path.split('/').slice(0, -1).join('/');
165
+ if (pageDir === parentDir) {
166
+ return pageId;
167
+ }
168
+ }
169
+
170
+ return null;
171
+ }