Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ confluence read "https://your-domain.atlassian.net/wiki/viewpage.action?pageId=1

Use `--format storage` when you need Confluence's native storage representation, especially for macros and other Confluence-specific markup.

Reading requires content with a storage body. Folders and other bodyless content return `Page <id> has no readable body (it may be a folder or an unsupported content type).`; use `confluence info <id>` to inspect their metadata.

### Get Page Information
```bash
confluence info 123456789
Expand All @@ -381,6 +383,8 @@ confluence info 123456789
confluence info 123456789 --json
```

`info` can inspect folders and other bodyless content because it only reads metadata.

Example JSON shape:
```json
{
Expand Down Expand Up @@ -640,6 +644,8 @@ confluence update 123456789 --file ./updated-content.md --format markdown
confluence update 123456789 --title "New Title" --content "And new content"
```

Title-only updates reuse the target's existing storage body. Folders and other bodyless content cannot be updated this way and return `Page <id> has no readable body (it may be a folder or an unsupported content type).`.

### Move a Page to New Parent

```bash
Expand Down Expand Up @@ -681,6 +687,8 @@ vim ./page-to-edit.xml
confluence update 123456789 --file ./page-to-edit.xml --format storage
```

Only content with a storage body can be exported for editing. Folders and other bodyless content return `Page <id> has no readable body (it may be a folder or an unsupported content type).`.

### Profile Management
```bash
# List all profiles and see which is active
Expand Down
32 changes: 28 additions & 4 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -329,13 +329,30 @@ class ConfluenceClient {
return referenced;
}

/**
* Extract the storage body value from a content API response, throwing a clear
* error when the content has no body (e.g. a folder or unsupported content type).
* @param {object} data - The `response.data` from a /content/:id request
* @param {string} pageId - The content ID, used in the error message
* @returns {string} The storage representation HTML
* @throws {Error} When the content has no storage body
*/
requireStorageBody(data, pageId) {
const value = data?.body?.storage?.value;
if (value === undefined || value === null) {
throw new Error(`Page ${pageId} has no readable body (it may be a folder or an unsupported content type).`);
}
return value;
}

/**
* Read a Confluence page content
* @param {string} pageIdOrUrl - Page ID or URL
* @param {string} format - Output format: 'text', 'html', 'storage', or 'markdown'
* @param {object} options - Additional options
* @param {boolean} options.resolveUsers - Whether to resolve userkeys to display names (default: true for markdown)
* @param {boolean} options.extractReferencedAttachments - Whether to extract referenced attachments (default: false)
* @throws {Error} When the target content has no storage body
*/
async readPage(pageIdOrUrl, format = 'text', options = {}) {
const pageId = await this.extractPageId(pageIdOrUrl);
Expand All @@ -346,8 +363,8 @@ class ConfluenceClient {
}
});

let htmlContent = response.data.body.storage.value;
let htmlContent = this.requireStorageBody(response.data, pageId);

// Extract referenced attachments if requested
if (options.extractReferencedAttachments) {
this._referencedAttachments = this.extractReferencedAttachments(htmlContent);
Expand Down Expand Up @@ -1352,6 +1369,11 @@ class ConfluenceClient {

/**
* Update an existing Confluence page
* @param {string} pageId - Page ID
* @param {string|null|undefined} title - New title; when omitted, current title is reused
* @param {string|null|undefined} content - New content; when omitted, existing storage body is reused
* @param {string} format - Input format: 'storage', 'html', 'markdown', or 'auto'
* @throws {Error} When content is omitted and the target has no storage body
*/
async updatePage(pageId, title, content, format = 'storage') {
// First, get the current page to get the version number and existing content
Expand All @@ -1368,7 +1390,7 @@ class ConfluenceClient {
storageContent = this.toStorageContent(content, format);
} else {
// If no new content, use the existing content
storageContent = currentPage.data.body.storage.value;
storageContent = this.requireStorageBody(currentPage.data, pageId);
}

const pageData = {
Expand Down Expand Up @@ -1442,6 +1464,8 @@ class ConfluenceClient {

/**
* Get page content for editing
* @param {string} pageIdOrUrl - Page ID or URL
* @throws {Error} When the target content has no storage body
*/
async getPageForEdit(pageIdOrUrl) {
const pageId = await this.extractPageId(pageIdOrUrl);
Expand All @@ -1455,7 +1479,7 @@ class ConfluenceClient {
return {
id: response.data.id,
title: response.data.title,
content: response.data.body.storage.value,
content: this.requireStorageBody(response.data, pageId),
version: response.data.version.number,
space: response.data.space
};
Expand Down
4 changes: 4 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ confluence init

This section summarizes recent improvements to align the codebase with its documentation and fix bugs.

### Bodyless Content Error Handling:
- **Inconsistency:** `confluence info` could inspect folders, but `read`, `edit`, and title-only `update` crashed when Confluence returned content without `body.storage.value`.
- **Fix:** Added a shared storage-body guard in `lib/confluence-client.js` so these commands fail with `Page <id> has no readable body (it may be a folder or an unsupported content type).`.

### `update` Command Logic and Documentation:
- **Inconsistency:** The `README.md` suggested that updating a page's title without changing its content was possible. However, the implementation in `bin/confluence.js` incorrectly threw an error if the `--content` or `--file` flags were not provided, making title-only updates impossible.
- **Fix:**
Expand Down
11 changes: 11 additions & 0 deletions plugins/confluence/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ confluence read 123456789 --format storage
confluence read 123456789 --format markdown
```

Requires content with a storage body. Folders and other bodyless content return `Page <id> has no readable body (it may be a folder or an unsupported content type).`; use `confluence info <id>` to inspect their metadata.

---

### `info <pageId>`
Expand All @@ -178,6 +180,8 @@ confluence info 123456789
confluence info 123456789 --format json
```

Works for folders and other bodyless content because it only reads metadata.

---

### `find <title>`
Expand Down Expand Up @@ -319,6 +323,8 @@ confluence update 123456789 --file ./updated.md --format markdown
confluence update 123456789 --title "New Title" --file ./updated.xml --format storage
```

Title-only updates reuse the target's existing storage body. Folders and other bodyless content cannot be updated this way and return `Page <id> has no readable body (it may be a folder or an unsupported content type).`.

---

### `move <pageId_or_url> <newParentId_or_url>`
Expand Down Expand Up @@ -376,6 +382,8 @@ confluence edit 123456789 --output ./page.xml
confluence update 123456789 --file ./page.xml --format storage
```

Only content with a storage body can be exported for editing. Folders and other bodyless content return `Page <id> has no readable body (it may be a folder or an unsupported content type).`.

---

### `export <pageId>`
Expand Down Expand Up @@ -700,6 +708,8 @@ confluence edit 123456789 --output ./page.xml
confluence update 123456789 --file ./page.xml --format storage
```

Requires content with a storage body; folders and other bodyless content should be inspected with `confluence info <id>` instead.

### Build a documentation hierarchy

```sh
Expand Down Expand Up @@ -773,6 +783,7 @@ confluence search --cql 'siteSearch ~ "release notes" and space = "MYSPACE"' --l
| 400 on inline comment creation | Editor metadata required | Use `--location footer` or reply to existing inline comment with `--parent` |
| `File not found: <path>` | `--file` path doesn't exist | Check the path before calling the command |
| `At least one of --title, --file, or --content must be provided` | `update` called with no content options | Provide at least one of the required options |
| `Page <id> has no readable body (it may be a folder or an unsupported content type).` | `read`, `edit`, or title-only `update` targeted a folder or other bodyless content | Use `info` for metadata, or target a page with a storage body |
| `Profile "<name>" not found!` | Specified profile doesn't exist | Run `confluence profile list` to see available profiles |
| `Cannot delete the only remaining profile.` | Tried to remove the last profile | Add another profile before removing |
| `This profile is in read-only mode` | Write command used with a read-only profile | Use a writable profile or remove `readOnly` from config |
45 changes: 45 additions & 0 deletions tests/confluence-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,51 @@ describe('ConfluenceClient', () => {
mock.restore();
});

describe('bodyless content (folder) handling', () => {
const NO_BODY_MESSAGE = /Page 123 has no readable body \(it may be a folder or an unsupported content type\)\./;

test('readPage throws a clear error instead of a TypeError', async () => {
const mock = new MockAdapter(client.client);
mock.onGet('/content/123').reply(200, { id: '123', type: 'folder', body: {} });

await expect(client.readPage('123', 'markdown')).rejects.toThrow(NO_BODY_MESSAGE);
await expect(client.readPage('123', 'markdown')).rejects.not.toThrow(/reading 'value'/);

mock.restore();
});

test('getPageForEdit throws a clear error instead of a TypeError', async () => {
const mock = new MockAdapter(client.client);
mock.onGet('/content/123').reply(200, {
id: '123',
type: 'folder',
body: {},
version: { number: 1 }
});

await expect(client.getPageForEdit('123')).rejects.toThrow(NO_BODY_MESSAGE);

mock.restore();
});

test('updatePage reuse-content path throws a clear error instead of a TypeError', async () => {
const mock = new MockAdapter(client.client);
mock.onGet('/content/123').reply(200, {
id: '123',
title: 'A folder',
type: 'folder',
body: {},
version: { number: 1 },
space: { key: 'ENG' }
});

// content omitted -> reuse-existing-content branch
await expect(client.updatePage('123', 'A folder', undefined)).rejects.toThrow(NO_BODY_MESSAGE);

mock.restore();
});
});

test('getPageInfo should normalize machine-readable metadata', async () => {
const mock = new MockAdapter(client.client);
mock.onGet('/content/123').reply(config => {
Expand Down