diff --git a/README.md b/README.md index 169b849..d946e4b 100644 --- a/README.md +++ b/README.md @@ -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 has no readable body (it may be a folder or an unsupported content type).`; use `confluence info ` to inspect their metadata. + ### Get Page Information ```bash confluence info 123456789 @@ -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 { @@ -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 has no readable body (it may be a folder or an unsupported content type).`. + ### Move a Page to New Parent ```bash @@ -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 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 diff --git a/lib/confluence-client.js b/lib/confluence-client.js index e0abb00..a2ca08e 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -329,6 +329,22 @@ 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 @@ -336,6 +352,7 @@ class ConfluenceClient { * @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); @@ -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); @@ -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 @@ -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 = { @@ -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); @@ -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 }; diff --git a/llms.txt b/llms.txt index a08aa94..a67409c 100644 --- a/llms.txt +++ b/llms.txt @@ -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 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:** diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 72a543f..62f99f1 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -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 has no readable body (it may be a folder or an unsupported content type).`; use `confluence info ` to inspect their metadata. + --- ### `info ` @@ -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 ` @@ -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>` @@ -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>` @@ -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 @@ -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 | diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 5df7844..40c0413 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -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 => {