confluence-cli 1.17.0 → 1.19.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/.github/workflows/ci.yml +3 -2
- package/CHANGELOG.md +14 -0
- package/README.md +26 -0
- package/bin/confluence.js +208 -1
- package/lib/confluence-client.js +94 -2
- package/package.json +1 -1
- package/tests/confluence-client.test.js +282 -0
package/.github/workflows/ci.yml
CHANGED
|
@@ -31,8 +31,9 @@ jobs:
|
|
|
31
31
|
runs-on: ubuntu-latest
|
|
32
32
|
steps:
|
|
33
33
|
- uses: actions/checkout@v3
|
|
34
|
-
-
|
|
35
|
-
|
|
34
|
+
- run: npm ci
|
|
35
|
+
- name: Run npm audit (production only)
|
|
36
|
+
run: npm audit --audit-level moderate --omit=dev
|
|
36
37
|
|
|
37
38
|
publish:
|
|
38
39
|
needs: [test, security]
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [1.19.0](https://github.com/pchuri/confluence-cli/compare/v1.18.0...v1.19.0) (2026-02-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add --cql flag to search command for raw CQL queries ([#40](https://github.com/pchuri/confluence-cli/issues/40)) ([311f5a9](https://github.com/pchuri/confluence-cli/commit/311f5a98bfd175c6b7902c55b4dd0687b2a0d8c0)), closes [#39](https://github.com/pchuri/confluence-cli/issues/39)
|
|
7
|
+
|
|
8
|
+
# [1.18.0](https://github.com/pchuri/confluence-cli/compare/v1.17.0...v1.18.0) (2026-02-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add content property commands (list, get, set, delete) ([#38](https://github.com/pchuri/confluence-cli/issues/38)) ([506515d](https://github.com/pchuri/confluence-cli/commit/506515dd0c271e5385ede3a49225d4b4707f225d)), closes [#37](https://github.com/pchuri/confluence-cli/issues/37)
|
|
14
|
+
|
|
1
15
|
# [1.17.0](https://github.com/pchuri/confluence-cli/compare/v1.16.0...v1.17.0) (2026-02-13)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
|
|
|
12
12
|
- 📝 **Update pages** - Update existing page content and titles
|
|
13
13
|
- 🗑️ **Delete pages** - Delete (or move to trash) pages by ID or URL
|
|
14
14
|
- 📎 **Attachments** - List, download, upload, or delete page attachments
|
|
15
|
+
- 🏷️ **Properties** - List, get, set, and delete content properties (key-value metadata)
|
|
15
16
|
- 💬 **Comments** - List, create, and delete page comments (footer or inline)
|
|
16
17
|
- 📦 **Export** - Save a page and its attachments to a local folder
|
|
17
18
|
- 🛠️ **Edit workflow** - Export page content for editing and re-import
|
|
@@ -181,6 +182,27 @@ confluence attachment-delete 123456789 998877
|
|
|
181
182
|
confluence attachment-delete 123456789 998877 --yes
|
|
182
183
|
```
|
|
183
184
|
|
|
185
|
+
### Content Properties
|
|
186
|
+
```bash
|
|
187
|
+
# List all properties on a page
|
|
188
|
+
confluence property-list 123456789
|
|
189
|
+
|
|
190
|
+
# Get a specific property
|
|
191
|
+
confluence property-get 123456789 my-key
|
|
192
|
+
|
|
193
|
+
# Set a property (creates or updates with auto-versioning)
|
|
194
|
+
confluence property-set 123456789 my-key --value '{"color":"#ff0000"}'
|
|
195
|
+
|
|
196
|
+
# Set a property from a JSON file
|
|
197
|
+
confluence property-set 123456789 my-key --file ./property.json
|
|
198
|
+
|
|
199
|
+
# Delete a property
|
|
200
|
+
confluence property-delete 123456789 my-key
|
|
201
|
+
|
|
202
|
+
# Skip confirmation on delete
|
|
203
|
+
confluence property-delete 123456789 my-key --yes
|
|
204
|
+
```
|
|
205
|
+
|
|
184
206
|
### Comments
|
|
185
207
|
```bash
|
|
186
208
|
# List all comments (footer + inline)
|
|
@@ -387,6 +409,10 @@ confluence stats
|
|
|
387
409
|
| `comments <pageId_or_url>` | List comments for a page | `--format <text\|markdown\|json>`, `--limit <number>`, `--start <number>`, `--location <inline\|footer\|resolved>`, `--depth <root\|all>`, `--all` |
|
|
388
410
|
| `comment <pageId_or_url>` | Create a comment on a page | `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>`, `--parent <commentId>`, `--location <inline\|footer>`, `--inline-selection <text>`, `--inline-original-selection <text>`, `--inline-marker-ref <ref>`, `--inline-properties <json>` |
|
|
389
411
|
| `comment-delete <commentId>` | Delete a comment by ID | `--yes` |
|
|
412
|
+
| `property-list <pageId_or_url>` | List all content properties for a page | `--format <text\|json>`, `--limit <number>`, `--start <number>`, `--all` |
|
|
413
|
+
| `property-get <pageId_or_url> <key>` | Get a content property by key | `--format <text\|json>` |
|
|
414
|
+
| `property-set <pageId_or_url> <key>` | Set a content property (create or update) | `--value <json>`, `--file <path>`, `--format <text\|json>` |
|
|
415
|
+
| `property-delete <pageId_or_url> <key>` | Delete a content property by key | `--yes` |
|
|
390
416
|
| `export <pageId_or_url>` | Export a page to a directory with its attachments | `--format <html\|text\|markdown>`, `--dest <directory>`, `--file <filename>`, `--attachments-dir <name>`, `--pattern <glob>`, `--referenced-only`, `--skip-attachments` |
|
|
391
417
|
| `stats` | View your usage statistics | |
|
|
392
418
|
|
package/bin/confluence.js
CHANGED
|
@@ -75,11 +75,12 @@ program
|
|
|
75
75
|
.command('search <query>')
|
|
76
76
|
.description('Search for Confluence pages')
|
|
77
77
|
.option('-l, --limit <limit>', 'Limit number of results', '10')
|
|
78
|
+
.option('--cql', 'Pass query as raw CQL instead of text search')
|
|
78
79
|
.action(async (query, options) => {
|
|
79
80
|
const analytics = new Analytics();
|
|
80
81
|
try {
|
|
81
82
|
const client = new ConfluenceClient(getConfig());
|
|
82
|
-
const results = await client.search(query, parseInt(options.limit));
|
|
83
|
+
const results = await client.search(query, parseInt(options.limit), options.cql);
|
|
83
84
|
|
|
84
85
|
if (results.length === 0) {
|
|
85
86
|
console.log(chalk.yellow('No results found.'));
|
|
@@ -597,6 +598,212 @@ program
|
|
|
597
598
|
}
|
|
598
599
|
});
|
|
599
600
|
|
|
601
|
+
// Property list command
|
|
602
|
+
program
|
|
603
|
+
.command('property-list <pageId>')
|
|
604
|
+
.description('List all content properties for a page')
|
|
605
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
606
|
+
.option('-l, --limit <limit>', 'Maximum number of properties to fetch (default: 25)')
|
|
607
|
+
.option('--start <start>', 'Start index for results (default: 0)', '0')
|
|
608
|
+
.option('--all', 'Fetch all properties (ignores pagination)')
|
|
609
|
+
.action(async (pageId, options) => {
|
|
610
|
+
const analytics = new Analytics();
|
|
611
|
+
try {
|
|
612
|
+
const config = getConfig();
|
|
613
|
+
const client = new ConfluenceClient(config);
|
|
614
|
+
|
|
615
|
+
const format = (options.format || 'text').toLowerCase();
|
|
616
|
+
if (!['text', 'json'].includes(format)) {
|
|
617
|
+
throw new Error('Format must be one of: text, json');
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const limit = options.limit ? parseInt(options.limit, 10) : null;
|
|
621
|
+
if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
|
|
622
|
+
throw new Error('Limit must be a positive number.');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const start = options.start ? parseInt(options.start, 10) : 0;
|
|
626
|
+
if (options.start && (Number.isNaN(start) || start < 0)) {
|
|
627
|
+
throw new Error('Start must be a non-negative number.');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let properties = [];
|
|
631
|
+
let nextStart = null;
|
|
632
|
+
|
|
633
|
+
if (options.all) {
|
|
634
|
+
properties = await client.getAllProperties(pageId, {
|
|
635
|
+
maxResults: limit || null,
|
|
636
|
+
start
|
|
637
|
+
});
|
|
638
|
+
} else {
|
|
639
|
+
const response = await client.listProperties(pageId, {
|
|
640
|
+
limit: limit || undefined,
|
|
641
|
+
start
|
|
642
|
+
});
|
|
643
|
+
properties = response.results;
|
|
644
|
+
nextStart = response.nextStart;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (format === 'json') {
|
|
648
|
+
const output = { properties };
|
|
649
|
+
if (!options.all) {
|
|
650
|
+
output.nextStart = nextStart;
|
|
651
|
+
}
|
|
652
|
+
console.log(JSON.stringify(output, null, 2));
|
|
653
|
+
} else if (properties.length === 0) {
|
|
654
|
+
console.log(chalk.yellow('No properties found.'));
|
|
655
|
+
} else {
|
|
656
|
+
properties.forEach((prop, i) => {
|
|
657
|
+
const preview = JSON.stringify(prop.value);
|
|
658
|
+
const truncated = preview.length > 80 ? preview.slice(0, 77) + '...' : preview;
|
|
659
|
+
console.log(`${chalk.blue(i + 1 + '.')} ${chalk.green(prop.key)} (v${prop.version.number}): ${truncated}`);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
if (!options.all && nextStart !== null && nextStart !== undefined) {
|
|
663
|
+
console.log(chalk.gray(`Next start: ${nextStart}`));
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
analytics.track('property_list', true);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
analytics.track('property_list', false);
|
|
669
|
+
console.error(chalk.red('Error:'), error.message);
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Property get command
|
|
675
|
+
program
|
|
676
|
+
.command('property-get <pageId> <key>')
|
|
677
|
+
.description('Get a content property by key')
|
|
678
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
679
|
+
.action(async (pageId, key, options) => {
|
|
680
|
+
const analytics = new Analytics();
|
|
681
|
+
try {
|
|
682
|
+
const config = getConfig();
|
|
683
|
+
const client = new ConfluenceClient(config);
|
|
684
|
+
|
|
685
|
+
const format = (options.format || 'text').toLowerCase();
|
|
686
|
+
if (!['text', 'json'].includes(format)) {
|
|
687
|
+
throw new Error('Format must be one of: text, json');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const property = await client.getProperty(pageId, key);
|
|
691
|
+
|
|
692
|
+
if (format === 'json') {
|
|
693
|
+
console.log(JSON.stringify(property, null, 2));
|
|
694
|
+
} else {
|
|
695
|
+
console.log(`${chalk.green('Key:')} ${property.key}`);
|
|
696
|
+
console.log(`${chalk.green('Version:')} ${property.version.number}`);
|
|
697
|
+
console.log(`${chalk.green('Value:')}`);
|
|
698
|
+
console.log(JSON.stringify(property.value, null, 2));
|
|
699
|
+
}
|
|
700
|
+
analytics.track('property_get', true);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
analytics.track('property_get', false);
|
|
703
|
+
console.error(chalk.red('Error:'), error.message);
|
|
704
|
+
process.exit(1);
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
// Property set command
|
|
709
|
+
program
|
|
710
|
+
.command('property-set <pageId> <key>')
|
|
711
|
+
.description('Set a content property (create or update)')
|
|
712
|
+
.option('-v, --value <json>', 'Property value as JSON')
|
|
713
|
+
.option('--file <file>', 'Read property value from a JSON file')
|
|
714
|
+
.option('-f, --format <format>', 'Output format (text, json)', 'text')
|
|
715
|
+
.action(async (pageId, key, options) => {
|
|
716
|
+
const analytics = new Analytics();
|
|
717
|
+
try {
|
|
718
|
+
const config = getConfig();
|
|
719
|
+
const client = new ConfluenceClient(config);
|
|
720
|
+
|
|
721
|
+
if (!options.value && !options.file) {
|
|
722
|
+
throw new Error('Provide a value with --value or --file.');
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
let value;
|
|
726
|
+
if (options.file) {
|
|
727
|
+
const fs = require('fs');
|
|
728
|
+
const raw = fs.readFileSync(options.file, 'utf-8');
|
|
729
|
+
try {
|
|
730
|
+
value = JSON.parse(raw);
|
|
731
|
+
} catch {
|
|
732
|
+
throw new Error(`Invalid JSON in file ${options.file}`);
|
|
733
|
+
}
|
|
734
|
+
} else {
|
|
735
|
+
try {
|
|
736
|
+
value = JSON.parse(options.value);
|
|
737
|
+
} catch {
|
|
738
|
+
throw new Error('Invalid JSON in --value');
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const format = (options.format || 'text').toLowerCase();
|
|
743
|
+
if (!['text', 'json'].includes(format)) {
|
|
744
|
+
throw new Error('Format must be one of: text, json');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const result = await client.setProperty(pageId, key, value);
|
|
748
|
+
|
|
749
|
+
if (format === 'json') {
|
|
750
|
+
console.log(JSON.stringify(result, null, 2));
|
|
751
|
+
} else {
|
|
752
|
+
console.log(chalk.green('✅ Property set successfully!'));
|
|
753
|
+
console.log(`${chalk.green('Key:')} ${result.key}`);
|
|
754
|
+
console.log(`${chalk.green('Version:')} ${result.version.number}`);
|
|
755
|
+
console.log(`${chalk.green('Value:')}`);
|
|
756
|
+
console.log(JSON.stringify(result.value, null, 2));
|
|
757
|
+
}
|
|
758
|
+
analytics.track('property_set', true);
|
|
759
|
+
} catch (error) {
|
|
760
|
+
analytics.track('property_set', false);
|
|
761
|
+
console.error(chalk.red('Error:'), error.message);
|
|
762
|
+
process.exit(1);
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Property delete command
|
|
767
|
+
program
|
|
768
|
+
.command('property-delete <pageId> <key>')
|
|
769
|
+
.description('Delete a content property by key')
|
|
770
|
+
.option('-y, --yes', 'Skip confirmation prompt')
|
|
771
|
+
.action(async (pageId, key, options) => {
|
|
772
|
+
const analytics = new Analytics();
|
|
773
|
+
try {
|
|
774
|
+
const config = getConfig();
|
|
775
|
+
const client = new ConfluenceClient(config);
|
|
776
|
+
|
|
777
|
+
if (!options.yes) {
|
|
778
|
+
const { confirmed } = await inquirer.prompt([
|
|
779
|
+
{
|
|
780
|
+
type: 'confirm',
|
|
781
|
+
name: 'confirmed',
|
|
782
|
+
default: false,
|
|
783
|
+
message: `Delete property "${key}" from page ${pageId}?`
|
|
784
|
+
}
|
|
785
|
+
]);
|
|
786
|
+
|
|
787
|
+
if (!confirmed) {
|
|
788
|
+
console.log(chalk.yellow('Cancelled.'));
|
|
789
|
+
analytics.track('property_delete_cancel', true);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const result = await client.deleteProperty(pageId, key);
|
|
795
|
+
|
|
796
|
+
console.log(chalk.green('✅ Property deleted successfully!'));
|
|
797
|
+
console.log(`${chalk.green('Key:')} ${chalk.blue(result.key)}`);
|
|
798
|
+
console.log(`${chalk.green('Page ID:')} ${chalk.blue(result.pageId)}`);
|
|
799
|
+
analytics.track('property_delete', true);
|
|
800
|
+
} catch (error) {
|
|
801
|
+
analytics.track('property_delete', false);
|
|
802
|
+
console.error(chalk.red('Error:'), error.message);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
600
807
|
// Comments command
|
|
601
808
|
program
|
|
602
809
|
.command('comments <pageId>')
|
package/lib/confluence-client.js
CHANGED
|
@@ -221,10 +221,11 @@ class ConfluenceClient {
|
|
|
221
221
|
/**
|
|
222
222
|
* Search for pages
|
|
223
223
|
*/
|
|
224
|
-
async search(query, limit = 10) {
|
|
224
|
+
async search(query, limit = 10, rawCql = false) {
|
|
225
|
+
const cql = rawCql ? query : `text ~ "${String(query).replace(/"/g, '\\"')}"`;
|
|
225
226
|
const response = await this.client.get('/search', {
|
|
226
227
|
params: {
|
|
227
|
-
cql
|
|
228
|
+
cql,
|
|
228
229
|
limit: limit
|
|
229
230
|
}
|
|
230
231
|
});
|
|
@@ -872,6 +873,97 @@ class ConfluenceClient {
|
|
|
872
873
|
return { id: String(attachmentId), pageId: String(pageId) };
|
|
873
874
|
}
|
|
874
875
|
|
|
876
|
+
/**
|
|
877
|
+
* List content properties for a page with pagination support
|
|
878
|
+
*/
|
|
879
|
+
async listProperties(pageIdOrUrl, options = {}) {
|
|
880
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
881
|
+
const limit = this.parsePositiveInt(options.limit, 25);
|
|
882
|
+
const start = this.parsePositiveInt(options.start, 0);
|
|
883
|
+
const params = { limit, start };
|
|
884
|
+
|
|
885
|
+
const response = await this.client.get(`/content/${pageId}/property`, { params });
|
|
886
|
+
const results = Array.isArray(response.data.results) ? response.data.results : [];
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
results,
|
|
890
|
+
nextStart: this.parseNextStart(response.data?._links?.next)
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Fetch all content properties for a page, honoring an optional maxResults cap
|
|
896
|
+
*/
|
|
897
|
+
async getAllProperties(pageIdOrUrl, options = {}) {
|
|
898
|
+
const pageSize = this.parsePositiveInt(options.pageSize || options.limit, 25);
|
|
899
|
+
const maxResults = this.parsePositiveInt(options.maxResults, null);
|
|
900
|
+
let start = this.parsePositiveInt(options.start, 0);
|
|
901
|
+
const properties = [];
|
|
902
|
+
|
|
903
|
+
let hasNext = true;
|
|
904
|
+
while (hasNext) {
|
|
905
|
+
const page = await this.listProperties(pageIdOrUrl, {
|
|
906
|
+
limit: pageSize,
|
|
907
|
+
start
|
|
908
|
+
});
|
|
909
|
+
properties.push(...page.results);
|
|
910
|
+
|
|
911
|
+
if (maxResults && properties.length >= maxResults) {
|
|
912
|
+
return properties.slice(0, maxResults);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
hasNext = page.nextStart !== null && page.nextStart !== undefined;
|
|
916
|
+
if (hasNext) {
|
|
917
|
+
start = page.nextStart;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
return properties;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Get a single content property by key
|
|
926
|
+
*/
|
|
927
|
+
async getProperty(pageIdOrUrl, key) {
|
|
928
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
929
|
+
const response = await this.client.get(`/content/${pageId}/property/${encodeURIComponent(key)}`);
|
|
930
|
+
return response.data;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Set (create or update) a content property
|
|
935
|
+
*/
|
|
936
|
+
async setProperty(pageIdOrUrl, key, value) {
|
|
937
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
938
|
+
const encodedKey = encodeURIComponent(key);
|
|
939
|
+
|
|
940
|
+
let version = 1;
|
|
941
|
+
try {
|
|
942
|
+
const existing = await this.client.get(`/content/${pageId}/property/${encodedKey}`);
|
|
943
|
+
version = existing.data.version.number + 1;
|
|
944
|
+
} catch (err) {
|
|
945
|
+
if (!err.response || err.response.status !== 404) {
|
|
946
|
+
throw err;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const response = await this.client.put(`/content/${pageId}/property/${encodedKey}`, {
|
|
951
|
+
key,
|
|
952
|
+
value,
|
|
953
|
+
version: { number: version }
|
|
954
|
+
});
|
|
955
|
+
return response.data;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Delete a content property by key
|
|
960
|
+
*/
|
|
961
|
+
async deleteProperty(pageIdOrUrl, key) {
|
|
962
|
+
const pageId = await this.extractPageId(pageIdOrUrl);
|
|
963
|
+
await this.client.delete(`/content/${pageId}/property/${encodeURIComponent(key)}`);
|
|
964
|
+
return { pageId: String(pageId), key };
|
|
965
|
+
}
|
|
966
|
+
|
|
875
967
|
/**
|
|
876
968
|
* Convert markdown to Confluence storage format
|
|
877
969
|
*/
|
package/package.json
CHANGED
|
@@ -303,6 +303,68 @@ describe('ConfluenceClient', () => {
|
|
|
303
303
|
});
|
|
304
304
|
});
|
|
305
305
|
|
|
306
|
+
describe('search', () => {
|
|
307
|
+
test('should wrap query in text search by default', async () => {
|
|
308
|
+
const mock = new MockAdapter(client.client);
|
|
309
|
+
mock.onGet('/search').reply((config) => {
|
|
310
|
+
expect(config.params.cql).toBe('text ~ "architecture decisions"');
|
|
311
|
+
expect(config.params.limit).toBe(10);
|
|
312
|
+
return [200, { results: [] }];
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const results = await client.search('architecture decisions');
|
|
316
|
+
expect(results).toEqual([]);
|
|
317
|
+
|
|
318
|
+
mock.restore();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test('should pass raw CQL when rawCql is true', async () => {
|
|
322
|
+
const mock = new MockAdapter(client.client);
|
|
323
|
+
const rawQuery = 'contributor = currentUser() order by lastmodified desc';
|
|
324
|
+
mock.onGet('/search').reply((config) => {
|
|
325
|
+
expect(config.params.cql).toBe(rawQuery);
|
|
326
|
+
return [200, {
|
|
327
|
+
results: [{
|
|
328
|
+
content: { id: '123', title: 'Test Page', type: 'page' },
|
|
329
|
+
excerpt: 'test excerpt'
|
|
330
|
+
}]
|
|
331
|
+
}];
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const results = await client.search(rawQuery, 10, true);
|
|
335
|
+
expect(results).toHaveLength(1);
|
|
336
|
+
expect(results[0].id).toBe('123');
|
|
337
|
+
expect(results[0].title).toBe('Test Page');
|
|
338
|
+
|
|
339
|
+
mock.restore();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('should escape double quotes in text search query', async () => {
|
|
343
|
+
const mock = new MockAdapter(client.client);
|
|
344
|
+
mock.onGet('/search').reply((config) => {
|
|
345
|
+
expect(config.params.cql).toBe('text ~ "test \\"quoted\\" term"');
|
|
346
|
+
return [200, { results: [] }];
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const results = await client.search('test "quoted" term');
|
|
350
|
+
expect(results).toEqual([]);
|
|
351
|
+
|
|
352
|
+
mock.restore();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('should respect limit parameter', async () => {
|
|
356
|
+
const mock = new MockAdapter(client.client);
|
|
357
|
+
mock.onGet('/search').reply((config) => {
|
|
358
|
+
expect(config.params.limit).toBe(5);
|
|
359
|
+
return [200, { results: [] }];
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
await client.search('test', 5);
|
|
363
|
+
|
|
364
|
+
mock.restore();
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
306
368
|
describe('page creation and updates', () => {
|
|
307
369
|
test('should have required methods for page management', () => {
|
|
308
370
|
expect(typeof client.createPage).toBe('function');
|
|
@@ -718,4 +780,224 @@ describe('ConfluenceClient', () => {
|
|
|
718
780
|
mock.restore();
|
|
719
781
|
});
|
|
720
782
|
});
|
|
783
|
+
|
|
784
|
+
describe('content properties', () => {
|
|
785
|
+
test('should have required methods for property handling', () => {
|
|
786
|
+
expect(typeof client.listProperties).toBe('function');
|
|
787
|
+
expect(typeof client.getProperty).toBe('function');
|
|
788
|
+
expect(typeof client.setProperty).toBe('function');
|
|
789
|
+
expect(typeof client.deleteProperty).toBe('function');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('listProperties should return results with pagination info', async () => {
|
|
793
|
+
const mock = new MockAdapter(client.client);
|
|
794
|
+
mock.onGet('/content/123/property').reply(200, {
|
|
795
|
+
results: [
|
|
796
|
+
{ key: 'color', value: { hex: '#ff0000' }, version: { number: 1 } },
|
|
797
|
+
{ key: 'status', value: 'active', version: { number: 3 } }
|
|
798
|
+
],
|
|
799
|
+
_links: { next: '/rest/api/content/123/property?start=2&limit=25' }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const response = await client.listProperties('123');
|
|
803
|
+
expect(response.results).toHaveLength(2);
|
|
804
|
+
expect(response.results[0].key).toBe('color');
|
|
805
|
+
expect(response.results[1].key).toBe('status');
|
|
806
|
+
expect(response.nextStart).toBe(2);
|
|
807
|
+
|
|
808
|
+
mock.restore();
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('listProperties should return empty results when no properties exist', async () => {
|
|
812
|
+
const mock = new MockAdapter(client.client);
|
|
813
|
+
mock.onGet('/content/456/property').reply(200, { results: [] });
|
|
814
|
+
|
|
815
|
+
const response = await client.listProperties('456');
|
|
816
|
+
expect(response.results).toEqual([]);
|
|
817
|
+
expect(response.nextStart).toBeNull();
|
|
818
|
+
|
|
819
|
+
mock.restore();
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test('listProperties should resolve page URLs', async () => {
|
|
823
|
+
const mock = new MockAdapter(client.client);
|
|
824
|
+
mock.onGet('/content/789/property').reply(200, { results: [] });
|
|
825
|
+
|
|
826
|
+
const response = await client.listProperties('https://test.atlassian.net/wiki/viewpage.action?pageId=789');
|
|
827
|
+
expect(response.results).toEqual([]);
|
|
828
|
+
|
|
829
|
+
mock.restore();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
test('listProperties should pass limit and start as query params', async () => {
|
|
833
|
+
const mock = new MockAdapter(client.client);
|
|
834
|
+
mock.onGet('/content/123/property').reply((config) => {
|
|
835
|
+
expect(config.params.limit).toBe(5);
|
|
836
|
+
expect(config.params.start).toBe(10);
|
|
837
|
+
return [200, { results: [] }];
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
await client.listProperties('123', { limit: 5, start: 10 });
|
|
841
|
+
|
|
842
|
+
mock.restore();
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test('getAllProperties should accumulate results across pages', async () => {
|
|
846
|
+
const mock = new MockAdapter(client.client);
|
|
847
|
+
let callCount = 0;
|
|
848
|
+
mock.onGet('/content/123/property').reply((config) => {
|
|
849
|
+
callCount++;
|
|
850
|
+
if (callCount === 1) {
|
|
851
|
+
expect(config.params.start).toBe(0);
|
|
852
|
+
return [200, {
|
|
853
|
+
results: [{ key: 'a', value: 1, version: { number: 1 } }],
|
|
854
|
+
_links: { next: '/rest/api/content/123/property?start=1&limit=1' }
|
|
855
|
+
}];
|
|
856
|
+
}
|
|
857
|
+
expect(config.params.start).toBe(1);
|
|
858
|
+
return [200, {
|
|
859
|
+
results: [{ key: 'b', value: 2, version: { number: 1 } }]
|
|
860
|
+
}];
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
const results = await client.getAllProperties('123', { pageSize: 1 });
|
|
864
|
+
expect(results).toHaveLength(2);
|
|
865
|
+
expect(results[0].key).toBe('a');
|
|
866
|
+
expect(results[1].key).toBe('b');
|
|
867
|
+
|
|
868
|
+
mock.restore();
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test('getProperty should return property data', async () => {
|
|
872
|
+
const mock = new MockAdapter(client.client);
|
|
873
|
+
mock.onGet('/content/123/property/color').reply(200, {
|
|
874
|
+
key: 'color',
|
|
875
|
+
value: { hex: '#ff0000' },
|
|
876
|
+
version: { number: 2 }
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
const result = await client.getProperty('123', 'color');
|
|
880
|
+
expect(result.key).toBe('color');
|
|
881
|
+
expect(result.value.hex).toBe('#ff0000');
|
|
882
|
+
|
|
883
|
+
mock.restore();
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('getProperty should throw on 404', async () => {
|
|
887
|
+
const mock = new MockAdapter(client.client);
|
|
888
|
+
mock.onGet('/content/123/property/missing').reply(404, { message: 'Not found' });
|
|
889
|
+
|
|
890
|
+
await expect(client.getProperty('123', 'missing')).rejects.toThrow();
|
|
891
|
+
|
|
892
|
+
mock.restore();
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
test('setProperty should create new property with version 1', async () => {
|
|
896
|
+
const mock = new MockAdapter(client.client);
|
|
897
|
+
mock.onGet('/content/123/property/newkey').reply(404);
|
|
898
|
+
mock.onPut('/content/123/property/newkey').reply((config) => {
|
|
899
|
+
const body = JSON.parse(config.data);
|
|
900
|
+
expect(body.version.number).toBe(1);
|
|
901
|
+
expect(body.key).toBe('newkey');
|
|
902
|
+
return [200, body];
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
const result = await client.setProperty('123', 'newkey', { data: true });
|
|
906
|
+
expect(result.version.number).toBe(1);
|
|
907
|
+
|
|
908
|
+
mock.restore();
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test('setProperty should auto-increment version for existing property', async () => {
|
|
912
|
+
const mock = new MockAdapter(client.client);
|
|
913
|
+
mock.onGet('/content/123/property/existing').reply(200, {
|
|
914
|
+
key: 'existing',
|
|
915
|
+
value: 'old',
|
|
916
|
+
version: { number: 5 }
|
|
917
|
+
});
|
|
918
|
+
mock.onPut('/content/123/property/existing').reply((config) => {
|
|
919
|
+
const body = JSON.parse(config.data);
|
|
920
|
+
expect(body.version.number).toBe(6);
|
|
921
|
+
return [200, body];
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const result = await client.setProperty('123', 'existing', 'new');
|
|
925
|
+
expect(result.version.number).toBe(6);
|
|
926
|
+
|
|
927
|
+
mock.restore();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
test('setProperty should propagate non-404 errors', async () => {
|
|
931
|
+
const mock = new MockAdapter(client.client);
|
|
932
|
+
mock.onGet('/content/123/property/broken').reply(500);
|
|
933
|
+
|
|
934
|
+
await expect(client.setProperty('123', 'broken', 'val')).rejects.toThrow();
|
|
935
|
+
|
|
936
|
+
mock.restore();
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
test('deleteProperty should call delete endpoint', async () => {
|
|
940
|
+
const mock = new MockAdapter(client.client);
|
|
941
|
+
mock.onDelete('/content/123/property/color').reply(204);
|
|
942
|
+
|
|
943
|
+
const result = await client.deleteProperty('123', 'color');
|
|
944
|
+
expect(result).toEqual({ pageId: '123', key: 'color' });
|
|
945
|
+
|
|
946
|
+
mock.restore();
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test('getProperty should URL-encode keys with reserved characters', async () => {
|
|
950
|
+
const mock = new MockAdapter(client.client);
|
|
951
|
+
mock.onGet('/content/123/property/my%20prop%2Fkey').reply(200, {
|
|
952
|
+
key: 'my prop/key',
|
|
953
|
+
value: { ok: true },
|
|
954
|
+
version: { number: 1 }
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
const result = await client.getProperty('123', 'my prop/key');
|
|
958
|
+
expect(result.key).toBe('my prop/key');
|
|
959
|
+
expect(result.value.ok).toBe(true);
|
|
960
|
+
|
|
961
|
+
mock.restore();
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
test('setProperty should URL-encode keys with reserved characters', async () => {
|
|
965
|
+
const mock = new MockAdapter(client.client);
|
|
966
|
+
mock.onGet('/content/123/property/my%20prop%2Fkey').reply(404);
|
|
967
|
+
mock.onPut('/content/123/property/my%20prop%2Fkey').reply((config) => {
|
|
968
|
+
const body = JSON.parse(config.data);
|
|
969
|
+
expect(body.key).toBe('my prop/key');
|
|
970
|
+
expect(body.version.number).toBe(1);
|
|
971
|
+
return [200, body];
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
const result = await client.setProperty('123', 'my prop/key', { test: true });
|
|
975
|
+
expect(result.key).toBe('my prop/key');
|
|
976
|
+
|
|
977
|
+
mock.restore();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test('deleteProperty should URL-encode keys with reserved characters', async () => {
|
|
981
|
+
const mock = new MockAdapter(client.client);
|
|
982
|
+
mock.onDelete('/content/123/property/my%20prop%2Fkey').reply(204);
|
|
983
|
+
|
|
984
|
+
const result = await client.deleteProperty('123', 'my prop/key');
|
|
985
|
+
expect(result).toEqual({ pageId: '123', key: 'my prop/key' });
|
|
986
|
+
|
|
987
|
+
mock.restore();
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
test('deleteProperty should resolve page URLs', async () => {
|
|
991
|
+
const mock = new MockAdapter(client.client);
|
|
992
|
+
mock.onDelete('/content/789/property/status').reply(204);
|
|
993
|
+
|
|
994
|
+
const result = await client.deleteProperty(
|
|
995
|
+
'https://test.atlassian.net/wiki/viewpage.action?pageId=789',
|
|
996
|
+
'status'
|
|
997
|
+
);
|
|
998
|
+
expect(result).toEqual({ pageId: '789', key: 'status' });
|
|
999
|
+
|
|
1000
|
+
mock.restore();
|
|
1001
|
+
});
|
|
1002
|
+
});
|
|
721
1003
|
});
|