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/README.md +111 -0
- package/package.json +25 -0
- package/src/agents-md.js +273 -0
- package/src/api.js +207 -0
- package/src/cli.js +496 -0
- package/src/git.js +137 -0
- package/src/hook.js +118 -0
- package/src/sync.js +278 -0
- package/src/tree.js +171 -0
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
|
+
}
|