@aaronshaf/confluence-cli 0.1.15 → 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 +1 -1
- package/package.json +1 -1
- package/src/cli/commands/folder.ts +189 -0
- package/src/cli/help.ts +30 -36
- package/src/cli/index.ts +12 -14
- package/src/lib/confluence-client/client.ts +19 -3
- package/src/lib/confluence-client/folder-operations.ts +41 -0
- package/src/lib/confluence-client/search-operations.ts +2 -1
- package/src/test/folder-command.test.ts +182 -0
- package/src/cli/commands/duplicate-check.ts +0 -89
- package/src/cli/commands/file-rename.ts +0 -113
- package/src/cli/commands/folder-hierarchy.ts +0 -241
- package/src/cli/commands/push-errors.ts +0 -40
- package/src/cli/commands/push.ts +0 -699
- package/src/lib/dependency-sorter.ts +0 -233
- package/src/test/dependency-sorter.test.ts +0 -384
- package/src/test/file-rename.test.ts +0 -305
- package/src/test/folder-hierarchy.test.ts +0 -337
- package/src/test/push.test.ts +0 -551
package/README.md
CHANGED
|
@@ -34,7 +34,6 @@ Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `
|
|
|
34
34
|
| `cn setup` | Configure Confluence credentials |
|
|
35
35
|
| `cn clone <SPACE_KEY>` | Clone a space to a new folder |
|
|
36
36
|
| `cn pull` | Pull changes from Confluence as markdown |
|
|
37
|
-
| `cn push [file]` | Push local markdown file(s) to Confluence |
|
|
38
37
|
| `cn status` | Check connection and sync status |
|
|
39
38
|
| `cn tree` | Display page hierarchy |
|
|
40
39
|
| `cn open [page]` | Open page in browser |
|
|
@@ -48,6 +47,7 @@ Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `
|
|
|
48
47
|
| `cn labels <id\|file>` | Manage page labels |
|
|
49
48
|
| `cn move <id\|file> <parentId>` | Move a page to a new parent |
|
|
50
49
|
| `cn attachments <id\|file>` | Manage page attachments |
|
|
50
|
+
| `cn folder <subcommand>` | Manage folders (create, list, delete, move) |
|
|
51
51
|
|
|
52
52
|
Run `cn <command> --help` for details on each command.
|
|
53
53
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
4
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
5
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
6
|
+
import { escapeXml } from '../../lib/formatters.js';
|
|
7
|
+
import { readSpaceConfig } from '../../lib/space-config.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract a flag value from an args array, e.g. --space DOCS -> "DOCS"
|
|
11
|
+
*/
|
|
12
|
+
function getFlagValue(args: string[], flag: string): string | undefined {
|
|
13
|
+
const idx = args.indexOf(flag);
|
|
14
|
+
if (idx !== -1 && idx + 1 < args.length && !args[idx + 1].startsWith('--')) {
|
|
15
|
+
return args[idx + 1];
|
|
16
|
+
}
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get positional args by stripping flags and their values
|
|
22
|
+
*/
|
|
23
|
+
function getPositionals(args: string[], flagsWithValues: string[]): string[] {
|
|
24
|
+
const result: string[] = [];
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i].startsWith('--')) {
|
|
27
|
+
if (flagsWithValues.includes(args[i])) {
|
|
28
|
+
i++; // skip the value too
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
result.push(args[i]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function folderCommand(subcommand: string, subArgs: string[], allArgs: string[]): Promise<void> {
|
|
38
|
+
const configManager = new ConfigManager();
|
|
39
|
+
const config = await configManager.getConfig();
|
|
40
|
+
|
|
41
|
+
if (!config) {
|
|
42
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
43
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const client = new ConfluenceClient(config);
|
|
47
|
+
|
|
48
|
+
switch (subcommand) {
|
|
49
|
+
case 'create': {
|
|
50
|
+
const positionals = getPositionals(subArgs, ['--space', '--parent']);
|
|
51
|
+
const title = positionals[0];
|
|
52
|
+
if (!title) {
|
|
53
|
+
console.error(chalk.red('Folder title is required.'));
|
|
54
|
+
console.log(chalk.gray('Usage: cn folder create <title> --space <key>'));
|
|
55
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const spaceKeyArg = getFlagValue(allArgs, '--space');
|
|
59
|
+
const parentId = getFlagValue(allArgs, '--parent');
|
|
60
|
+
|
|
61
|
+
let spaceId: string;
|
|
62
|
+
|
|
63
|
+
if (spaceKeyArg) {
|
|
64
|
+
const space = await client.getSpaceByKey(spaceKeyArg);
|
|
65
|
+
spaceId = space.id;
|
|
66
|
+
} else {
|
|
67
|
+
const spaceConfig = readSpaceConfig(process.cwd());
|
|
68
|
+
if (!spaceConfig) {
|
|
69
|
+
console.error(chalk.red('Not in a cloned space directory. Use --space to specify a space key.'));
|
|
70
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
71
|
+
}
|
|
72
|
+
spaceId = spaceConfig.spaceId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const folder = await client.createFolder({ spaceId, title, parentId });
|
|
76
|
+
console.log(`${chalk.green('Created:')} "${folder.title}" (${folder.id})`);
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case 'list': {
|
|
81
|
+
const spaceKeyArg = getFlagValue(allArgs, '--space');
|
|
82
|
+
|
|
83
|
+
let spaceKey: string;
|
|
84
|
+
|
|
85
|
+
if (spaceKeyArg) {
|
|
86
|
+
spaceKey = spaceKeyArg;
|
|
87
|
+
} else {
|
|
88
|
+
const spaceConfig = readSpaceConfig(process.cwd());
|
|
89
|
+
if (!spaceConfig) {
|
|
90
|
+
console.error(chalk.red('Not in a cloned space directory. Use --space to specify a space key.'));
|
|
91
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
92
|
+
}
|
|
93
|
+
spaceKey = spaceConfig.spaceKey;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cql = `type=folder AND space="${spaceKey.replace(/"/g, '\\"')}"`;
|
|
97
|
+
const PAGE_SIZE = 100;
|
|
98
|
+
const allResults: (typeof firstPage.results)[number][] = [];
|
|
99
|
+
let start = 0;
|
|
100
|
+
|
|
101
|
+
const firstPage = await client.search(cql, PAGE_SIZE, start);
|
|
102
|
+
allResults.push(...firstPage.results);
|
|
103
|
+
const total = firstPage.totalSize ?? firstPage.results.length;
|
|
104
|
+
|
|
105
|
+
while (allResults.length < total) {
|
|
106
|
+
start += PAGE_SIZE;
|
|
107
|
+
const page = await client.search(cql, PAGE_SIZE, start);
|
|
108
|
+
if (page.results.length === 0) break;
|
|
109
|
+
allResults.push(...page.results);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const xml = allArgs.includes('--xml');
|
|
113
|
+
|
|
114
|
+
if (xml) {
|
|
115
|
+
console.log('<folders>');
|
|
116
|
+
for (const result of allResults) {
|
|
117
|
+
const c = result.content;
|
|
118
|
+
if (c) {
|
|
119
|
+
console.log(` <folder>`);
|
|
120
|
+
console.log(` <id>${escapeXml(c.id ?? '')}</id>`);
|
|
121
|
+
console.log(` <title>${escapeXml(c.title ?? '')}</title>`);
|
|
122
|
+
console.log(` </folder>`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.log('</folders>');
|
|
126
|
+
} else {
|
|
127
|
+
if (allResults.length === 0) {
|
|
128
|
+
console.log(chalk.gray('No folders found.'));
|
|
129
|
+
} else {
|
|
130
|
+
for (const result of allResults) {
|
|
131
|
+
const c = result.content;
|
|
132
|
+
if (c) {
|
|
133
|
+
console.log(`${chalk.cyan(c.id)} ${c.title}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case 'delete': {
|
|
142
|
+
const positionals = getPositionals(subArgs, []);
|
|
143
|
+
const folderId = positionals[0];
|
|
144
|
+
if (!folderId) {
|
|
145
|
+
console.error(chalk.red('Folder ID is required.'));
|
|
146
|
+
console.log(chalk.gray('Usage: cn folder delete <id>'));
|
|
147
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!allArgs.includes('--force')) {
|
|
151
|
+
const folder = await client.getFolder(folderId);
|
|
152
|
+
const confirmed = await confirm({
|
|
153
|
+
message: `Delete folder "${folder.title}" (${folderId})?`,
|
|
154
|
+
default: false,
|
|
155
|
+
});
|
|
156
|
+
if (!confirmed) {
|
|
157
|
+
console.log('Cancelled.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await client.deleteFolder(folderId);
|
|
163
|
+
console.log(`${chalk.green('Deleted:')} ${folderId}`);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case 'move': {
|
|
168
|
+
const positionals = getPositionals(subArgs, []);
|
|
169
|
+
if (positionals.length < 2) {
|
|
170
|
+
console.error(chalk.red('Folder ID and parent ID are required.'));
|
|
171
|
+
console.log(chalk.gray('Usage: cn folder move <id> <parentId>'));
|
|
172
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const folderId = positionals[0] as string;
|
|
176
|
+
const parentId = positionals[1] as string;
|
|
177
|
+
const [folder, parent] = await Promise.all([client.getFolder(folderId), client.getFolder(parentId)]);
|
|
178
|
+
|
|
179
|
+
await client.movePage(folderId, parentId);
|
|
180
|
+
console.log(`${chalk.green('Moved:')} "${folder.title}" under "${parent.title}"`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
default:
|
|
185
|
+
console.error(chalk.red(`Unknown folder subcommand: ${subcommand}`));
|
|
186
|
+
console.log(chalk.gray('Run "cn folder --help" for usage information.'));
|
|
187
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/src/cli/help.ts
CHANGED
|
@@ -89,41 +89,6 @@ ${chalk.yellow('Examples:')}
|
|
|
89
89
|
`);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function showPushHelp(): void {
|
|
93
|
-
console.log(`
|
|
94
|
-
${chalk.bold('cn push - Push local markdown files to Confluence')}
|
|
95
|
-
|
|
96
|
-
${chalk.yellow('Usage:')}
|
|
97
|
-
cn push [file] [options]
|
|
98
|
-
|
|
99
|
-
${chalk.yellow('Description:')}
|
|
100
|
-
Push local markdown files to Confluence.
|
|
101
|
-
|
|
102
|
-
With a file argument: pushes that single file.
|
|
103
|
-
Without arguments: scans for changed files and prompts y/n for each.
|
|
104
|
-
|
|
105
|
-
${chalk.yellow('Arguments:')}
|
|
106
|
-
file Path to markdown file (optional)
|
|
107
|
-
|
|
108
|
-
${chalk.yellow('Options:')}
|
|
109
|
-
--force Ignore version conflicts and overwrite
|
|
110
|
-
--dry-run Preview changes without pushing
|
|
111
|
-
--help Show this help message
|
|
112
|
-
|
|
113
|
-
${chalk.yellow('Examples:')}
|
|
114
|
-
cn push Scan and prompt for all changed files
|
|
115
|
-
cn push --dry-run Preview what would be pushed
|
|
116
|
-
cn push ./docs/page.md Push single page
|
|
117
|
-
cn push ./docs/page.md --force Force push (ignore version conflict)
|
|
118
|
-
|
|
119
|
-
${chalk.yellow('Notes:')}
|
|
120
|
-
- New files (no page_id) will be created on Confluence
|
|
121
|
-
- Modified files are detected by comparing file mtime vs synced_at
|
|
122
|
-
- Only basic markdown elements are fully supported
|
|
123
|
-
- Files are automatically renamed to match page titles (except index.md/README.md)
|
|
124
|
-
`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
92
|
export function showStatusHelp(): void {
|
|
128
93
|
console.log(`
|
|
129
94
|
${chalk.bold('cn status - Check connection and sync status')}
|
|
@@ -316,6 +281,35 @@ ${chalk.yellow('Options:')}
|
|
|
316
281
|
`);
|
|
317
282
|
}
|
|
318
283
|
|
|
284
|
+
export function showFolderHelp(): void {
|
|
285
|
+
console.log(`
|
|
286
|
+
${chalk.bold('cn folder - Manage Confluence folders')}
|
|
287
|
+
|
|
288
|
+
${chalk.yellow('Usage:')}
|
|
289
|
+
cn folder <subcommand> [options]
|
|
290
|
+
|
|
291
|
+
${chalk.yellow('Subcommands:')}
|
|
292
|
+
create <title> Create a new folder
|
|
293
|
+
list List folders in a space
|
|
294
|
+
delete <id> Delete a folder
|
|
295
|
+
move <id> <parentId> Move a folder to a new parent
|
|
296
|
+
|
|
297
|
+
${chalk.yellow('Options:')}
|
|
298
|
+
--space <key> Space key (required for create/list if not in cloned dir)
|
|
299
|
+
--parent <id> Parent folder ID (for create)
|
|
300
|
+
--force Skip confirmation prompt (for delete)
|
|
301
|
+
--xml Output in XML format (for list)
|
|
302
|
+
--help Show this help message
|
|
303
|
+
|
|
304
|
+
${chalk.yellow('Examples:')}
|
|
305
|
+
cn folder create "My Folder" --space DOCS
|
|
306
|
+
cn folder create "Nested" --space DOCS --parent 123456
|
|
307
|
+
cn folder list --space DOCS
|
|
308
|
+
cn folder delete 123456
|
|
309
|
+
cn folder move 123456 789012
|
|
310
|
+
`);
|
|
311
|
+
}
|
|
312
|
+
|
|
319
313
|
export function showMoveHelp(): void {
|
|
320
314
|
console.log(`
|
|
321
315
|
${chalk.bold('cn move - Move a page to a new parent')}
|
|
@@ -386,7 +380,7 @@ ${chalk.yellow('Commands:')}
|
|
|
386
380
|
cn setup Configure Confluence credentials
|
|
387
381
|
cn clone Clone a space to a new folder
|
|
388
382
|
cn pull Pull space to local folder
|
|
389
|
-
cn
|
|
383
|
+
cn folder Manage Confluence folders
|
|
390
384
|
cn status Check connection and sync status
|
|
391
385
|
cn tree Display page hierarchy
|
|
392
386
|
cn open Open page in browser
|
package/src/cli/index.ts
CHANGED
|
@@ -16,8 +16,8 @@ import {
|
|
|
16
16
|
showLabelsHelp,
|
|
17
17
|
showMoveHelp,
|
|
18
18
|
showOpenHelp,
|
|
19
|
+
showFolderHelp,
|
|
19
20
|
showPullHelp,
|
|
20
|
-
showPushHelp,
|
|
21
21
|
showSearchHelp,
|
|
22
22
|
showSetupHelp,
|
|
23
23
|
showSpacesHelp,
|
|
@@ -34,8 +34,8 @@ import { infoCommand } from './commands/info.js';
|
|
|
34
34
|
import { labelsCommand } from './commands/labels.js';
|
|
35
35
|
import { moveCommand } from './commands/move.js';
|
|
36
36
|
import { openCommand } from './commands/open.js';
|
|
37
|
+
import { folderCommand } from './commands/folder.js';
|
|
37
38
|
import { pullCommand } from './commands/pull.js';
|
|
38
|
-
import { pushCommand } from './commands/push.js';
|
|
39
39
|
import { searchCommand } from './commands/search.js';
|
|
40
40
|
import { setup } from './commands/setup.js';
|
|
41
41
|
import { spacesCommand } from './commands/spaces.js';
|
|
@@ -128,20 +128,18 @@ async function main(): Promise<void> {
|
|
|
128
128
|
break;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
case '
|
|
132
|
-
if (args.includes('--help')) {
|
|
133
|
-
|
|
131
|
+
case 'folder': {
|
|
132
|
+
if (args.includes('--help') && !subArgs.find((a) => !a.startsWith('--'))) {
|
|
133
|
+
showFolderHelp();
|
|
134
134
|
process.exit(EXIT_CODES.SUCCESS);
|
|
135
135
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
dryRun: args.includes('--dry-run'),
|
|
144
|
-
});
|
|
136
|
+
const folderSubcommand = subArgs[0];
|
|
137
|
+
if (!folderSubcommand || folderSubcommand.startsWith('--')) {
|
|
138
|
+
showFolderHelp();
|
|
139
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
140
|
+
}
|
|
141
|
+
const folderSubArgs = subArgs.slice(1);
|
|
142
|
+
await folderCommand(folderSubcommand, folderSubArgs, args);
|
|
145
143
|
break;
|
|
146
144
|
}
|
|
147
145
|
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '../errors.js';
|
|
13
13
|
import {
|
|
14
14
|
createFolderEffect as createFolderEffectFn,
|
|
15
|
+
deleteFolderEffect as deleteFolderEffectFn,
|
|
15
16
|
findFolderByTitle as findFolderByTitleFn,
|
|
16
17
|
getFolderEffect as getFolderEffectFn,
|
|
17
18
|
movePageEffect as movePageEffectFn,
|
|
@@ -479,6 +480,20 @@ export class ConfluenceClient {
|
|
|
479
480
|
return Effect.runPromise(this.createFolderEffect(request));
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Delete a folder by ID (Effect version)
|
|
485
|
+
*/
|
|
486
|
+
deleteFolderEffect(
|
|
487
|
+
folderId: string,
|
|
488
|
+
): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
|
|
489
|
+
return deleteFolderEffectFn(this.baseUrl, this.authHeader, folderId);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Delete a folder by ID (async version) */
|
|
493
|
+
async deleteFolder(folderId: string): Promise<void> {
|
|
494
|
+
return Effect.runPromise(this.deleteFolderEffect(folderId));
|
|
495
|
+
}
|
|
496
|
+
|
|
482
497
|
/**
|
|
483
498
|
* Find a folder by title in a space
|
|
484
499
|
* Uses v1 CQL search API to find folders by title
|
|
@@ -514,13 +529,14 @@ export class ConfluenceClient {
|
|
|
514
529
|
searchEffect(
|
|
515
530
|
cql: string,
|
|
516
531
|
limit = 10,
|
|
532
|
+
start = 0,
|
|
517
533
|
): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
518
|
-
return searchEffectFn(this.baseUrl, this.authHeader, cql, limit);
|
|
534
|
+
return searchEffectFn(this.baseUrl, this.authHeader, cql, limit, start);
|
|
519
535
|
}
|
|
520
536
|
|
|
521
537
|
/** Search pages using CQL (async version) */
|
|
522
|
-
async search(cql: string, limit = 10): Promise<SearchResponse> {
|
|
523
|
-
return Effect.runPromise(this.searchEffect(cql, limit));
|
|
538
|
+
async search(cql: string, limit = 10, start = 0): Promise<SearchResponse> {
|
|
539
|
+
return Effect.runPromise(this.searchEffect(cql, limit, start));
|
|
524
540
|
}
|
|
525
541
|
|
|
526
542
|
// ================== Comments API ==================
|
|
@@ -118,6 +118,47 @@ export function createFolderEffect(
|
|
|
118
118
|
);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Delete a folder by ID (Effect version)
|
|
123
|
+
*/
|
|
124
|
+
export function deleteFolderEffect(
|
|
125
|
+
baseUrl: string,
|
|
126
|
+
authHeader: string,
|
|
127
|
+
folderId: string,
|
|
128
|
+
): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
|
|
129
|
+
const makeRequest = Effect.tryPromise({
|
|
130
|
+
try: async () => {
|
|
131
|
+
const url = `${baseUrl}/wiki/api/v2/folders/${folderId}`;
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
method: 'DELETE',
|
|
134
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (response.status === 404) throw new FolderNotFoundError(folderId);
|
|
138
|
+
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
139
|
+
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
140
|
+
if (response.status === 429) {
|
|
141
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
142
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
143
|
+
}
|
|
144
|
+
if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
|
|
145
|
+
},
|
|
146
|
+
catch: (error) => {
|
|
147
|
+
if (
|
|
148
|
+
error instanceof FolderNotFoundError ||
|
|
149
|
+
error instanceof AuthError ||
|
|
150
|
+
error instanceof ApiError ||
|
|
151
|
+
error instanceof RateLimitError
|
|
152
|
+
) {
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
return new NetworkError(`Network error: ${error}`);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
/**
|
|
122
163
|
* Move a page to a new parent (Effect version)
|
|
123
164
|
* Uses v1 API: PUT /wiki/rest/api/content/{id}/move/{position}/{targetId}
|
|
@@ -21,8 +21,9 @@ export function searchEffect(
|
|
|
21
21
|
authHeader: string,
|
|
22
22
|
cql: string,
|
|
23
23
|
limit = 10,
|
|
24
|
+
start = 0,
|
|
24
25
|
): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
25
|
-
const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}`;
|
|
26
|
+
const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}&start=${start}`;
|
|
26
27
|
|
|
27
28
|
const makeRequest = Effect.tryPromise({
|
|
28
29
|
try: async () => {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { HttpResponse, http } from 'msw';
|
|
3
|
+
import { ConfluenceClient } from '../lib/confluence-client/client.js';
|
|
4
|
+
import { server } from './setup-msw.js';
|
|
5
|
+
import { createValidFolder, createValidSpace } from './msw-schema-validation.js';
|
|
6
|
+
|
|
7
|
+
const testConfig = {
|
|
8
|
+
confluenceUrl: 'https://test.atlassian.net',
|
|
9
|
+
email: 'test@example.com',
|
|
10
|
+
apiToken: 'test-token',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('cn folder - API layer', () => {
|
|
14
|
+
describe('createFolder', () => {
|
|
15
|
+
test('creates a folder with spaceId resolved from getSpaceByKey', async () => {
|
|
16
|
+
let capturedBody: unknown;
|
|
17
|
+
|
|
18
|
+
server.use(
|
|
19
|
+
http.get('*/wiki/api/v2/spaces', ({ request }) => {
|
|
20
|
+
const url = new URL(request.url);
|
|
21
|
+
const keys = url.searchParams.get('keys');
|
|
22
|
+
return HttpResponse.json({ results: [createValidSpace({ id: 'space-456', key: keys ?? 'DOCS' })] });
|
|
23
|
+
}),
|
|
24
|
+
http.post('*/wiki/api/v2/folders', async ({ request }) => {
|
|
25
|
+
capturedBody = await request.json();
|
|
26
|
+
return HttpResponse.json(createValidFolder({ id: 'folder-new', title: 'My Folder' }));
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const client = new ConfluenceClient(testConfig);
|
|
31
|
+
const space = await client.getSpaceByKey('DOCS');
|
|
32
|
+
const folder = await client.createFolder({ spaceId: space.id, title: 'My Folder' });
|
|
33
|
+
|
|
34
|
+
expect(space.id).toBe('space-456');
|
|
35
|
+
expect(folder.id).toBe('folder-new');
|
|
36
|
+
expect(folder.title).toBe('My Folder');
|
|
37
|
+
expect((capturedBody as { spaceId: string }).spaceId).toBe('space-456');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('creates a folder with a parentId', async () => {
|
|
41
|
+
let capturedBody: unknown;
|
|
42
|
+
|
|
43
|
+
server.use(
|
|
44
|
+
http.post('*/wiki/api/v2/folders', async ({ request }) => {
|
|
45
|
+
capturedBody = await request.json();
|
|
46
|
+
return HttpResponse.json(
|
|
47
|
+
createValidFolder({ id: 'folder-child', title: 'Child Folder', parentId: 'folder-parent' }),
|
|
48
|
+
);
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const client = new ConfluenceClient(testConfig);
|
|
53
|
+
await client.createFolder({ spaceId: 'space-123', title: 'Child Folder', parentId: 'folder-parent' });
|
|
54
|
+
|
|
55
|
+
expect((capturedBody as { parentId: string }).parentId).toBe('folder-parent');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('deleteFolder', () => {
|
|
60
|
+
test('deletes a folder by ID', async () => {
|
|
61
|
+
let deletedId = '';
|
|
62
|
+
|
|
63
|
+
server.use(
|
|
64
|
+
http.delete('*/wiki/api/v2/folders/:folderId', ({ params }) => {
|
|
65
|
+
deletedId = params.folderId as string;
|
|
66
|
+
return new HttpResponse(null, { status: 204 });
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const client = new ConfluenceClient(testConfig);
|
|
71
|
+
await client.deleteFolder('folder-123');
|
|
72
|
+
|
|
73
|
+
expect(deletedId).toBe('folder-123');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('throws FolderNotFoundError for 404', async () => {
|
|
77
|
+
server.use(
|
|
78
|
+
http.delete('*/wiki/api/v2/folders/:folderId', () => {
|
|
79
|
+
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const client = new ConfluenceClient(testConfig);
|
|
84
|
+
await expect(client.deleteFolder('nonexistent')).rejects.toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('folder list pagination', () => {
|
|
89
|
+
test('fetches a single page when total fits in one request', async () => {
|
|
90
|
+
server.use(
|
|
91
|
+
http.get('*/wiki/rest/api/search', ({ request }) => {
|
|
92
|
+
const url = new URL(request.url);
|
|
93
|
+
const start = Number(url.searchParams.get('start') ?? 0);
|
|
94
|
+
if (start === 0) {
|
|
95
|
+
return HttpResponse.json({
|
|
96
|
+
results: [
|
|
97
|
+
{ content: { id: 'f1', type: 'folder', title: 'Folder 1' } },
|
|
98
|
+
{ content: { id: 'f2', type: 'folder', title: 'Folder 2' } },
|
|
99
|
+
],
|
|
100
|
+
totalSize: 2,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return HttpResponse.json({ results: [], totalSize: 2 });
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const client = new ConfluenceClient(testConfig);
|
|
108
|
+
const page1 = await client.search('type=folder AND space="TEST"', 100, 0);
|
|
109
|
+
expect(page1.results).toHaveLength(2);
|
|
110
|
+
expect(page1.totalSize).toBe(2);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('paginates when totalSize exceeds page size', async () => {
|
|
114
|
+
const allFolders = Array.from({ length: 150 }, (_, i) => ({
|
|
115
|
+
content: { id: `f${i}`, type: 'folder', title: `Folder ${i}` },
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
server.use(
|
|
119
|
+
http.get('*/wiki/rest/api/search', ({ request }) => {
|
|
120
|
+
const url = new URL(request.url);
|
|
121
|
+
const start = Number(url.searchParams.get('start') ?? 0);
|
|
122
|
+
const limit = Number(url.searchParams.get('limit') ?? 10);
|
|
123
|
+
const slice = allFolders.slice(start, start + limit);
|
|
124
|
+
return HttpResponse.json({ results: slice, totalSize: 150 });
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const client = new ConfluenceClient(testConfig);
|
|
129
|
+
const collected = [];
|
|
130
|
+
let start = 0;
|
|
131
|
+
const PAGE_SIZE = 100;
|
|
132
|
+
|
|
133
|
+
const first = await client.search('type=folder AND space="TEST"', PAGE_SIZE, start);
|
|
134
|
+
collected.push(...first.results);
|
|
135
|
+
const total = first.totalSize ?? first.results.length;
|
|
136
|
+
|
|
137
|
+
while (collected.length < total) {
|
|
138
|
+
start += PAGE_SIZE;
|
|
139
|
+
const page = await client.search('type=folder AND space="TEST"', PAGE_SIZE, start);
|
|
140
|
+
if (page.results.length === 0) break;
|
|
141
|
+
collected.push(...page.results);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
expect(collected).toHaveLength(150);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('getPositionals (arg parsing)', () => {
|
|
149
|
+
// Test the arg parsing logic directly via the folderCommand behavior
|
|
150
|
+
test('search passes start param in URL', async () => {
|
|
151
|
+
const capturedUrls: string[] = [];
|
|
152
|
+
|
|
153
|
+
server.use(
|
|
154
|
+
http.get('*/wiki/rest/api/search', ({ request }) => {
|
|
155
|
+
capturedUrls.push(request.url);
|
|
156
|
+
return HttpResponse.json({ results: [], totalSize: 0 });
|
|
157
|
+
}),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const client = new ConfluenceClient(testConfig);
|
|
161
|
+
await client.search('type=folder', 100, 42);
|
|
162
|
+
|
|
163
|
+
expect(capturedUrls[0]).toContain('start=42');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('search passes limit param in URL', async () => {
|
|
167
|
+
const capturedUrls: string[] = [];
|
|
168
|
+
|
|
169
|
+
server.use(
|
|
170
|
+
http.get('*/wiki/rest/api/search', ({ request }) => {
|
|
171
|
+
capturedUrls.push(request.url);
|
|
172
|
+
return HttpResponse.json({ results: [], totalSize: 0 });
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const client = new ConfluenceClient(testConfig);
|
|
177
|
+
await client.search('type=folder', 50, 0);
|
|
178
|
+
|
|
179
|
+
expect(capturedUrls[0]).toContain('limit=50');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|