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
39 changes: 35 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,37 @@ confluence property-delete 123456789 my-key
confluence property-delete 123456789 my-key --yes
```

#### Content properties in Markdown front matter

Markdown files can carry selected content properties (for example page width) in YAML front matter, so property changes can be reviewed in git and applied through the normal upload workflow.

```markdown
---
properties:
content-appearance-published: full-width
---

# Page title
```

```bash
# Export a page with the listed properties as front matter
# (keys that do not exist on the page are omitted, in the order given)
confluence read 123456789 --format markdown --front-matter content-appearance-published > page.md

# Upload the body and apply the front matter properties
confluence update 123456789 --file ./page.md --format markdown --front-matter
confluence create "Page title" MYSPACE --file ./page.md --format markdown --front-matter
confluence create-child "Page title" 123456789 --file ./page.md --format markdown --front-matter
```

- Front matter is only processed with `--front-matter`; without it, a leading `---` stays part of the Markdown body (a thematic break), as before.
- Only the `properties` map is used; other top-level keys are ignored (a warning is printed if the block has no `properties` map). Properties not listed are left untouched, and properties whose value already matches are not rewritten.
- On Confluence Cloud the editor also keeps a `content-appearance-draft` property; set it alongside `content-appearance-published` so the width survives the next edit in the browser.
- Front matter is parsed before anything is uploaded, so invalid YAML fails without changing the page.
- The body is saved first, then the properties. If a property fails (for example because the token lacks the needed scope), the command exits non-zero and reports that the body was saved and which keys failed. With `--json`, the result on stdout includes `properties: { applied, unchanged, failed }` and stderr carries a `PARTIAL_FAILURE` error. Re-running `update` is safe: properties that already match are skipped. For `create` and `create-child`, the error names the `update` command that retries the properties on the new page (re-running `create` would try to make another page).
- `read` → edit → `update` round trips keep the selected properties. Markdown output is stable and normalized, not byte-identical to the original file.

### Comments
```bash
# List all comments (footer + inline)
Expand Down Expand Up @@ -1056,16 +1087,16 @@ confluence stats
| Command | Description | Options |
|---|---|---|
| `init` | Initialize CLI configuration | `--read-only`, `--keychain` |
| `read <pageId_or_url>` | Read page content | `--format <html\|text\|storage\|markdown>` |
| `read <pageId_or_url>` | Read page content | `--format <html\|text\|storage\|markdown>`, `--front-matter <keys>` |
| `info <pageId_or_url>` | Get page information | `--json` |
| `search <query>` | Search for pages | `--json`, `--limit <number>`, `--start <number>` |
| `spaces` | List available spaces | `--json`, `--limit <number>`, `--all` |
| `find <title>` | Find a page by its title | `--space <spaceKey>`, `--json` |
| `children <pageId>` | List child pages and folders of a page | `--recursive`, `--max-depth <number>`, `--type <pages\|folders\|all>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` |
| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` |
| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` |
| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--front-matter`, `--json` |
| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--front-matter`, `--json` |
| `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet`, `--json` |
| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--json` |
| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--front-matter`, `--json` |
| `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>`, `--json` |
| `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes`, `--json` |
| `versions <pageId_or_url>` | List historical versions of a page | `--json` |
Expand Down
139 changes: 129 additions & 10 deletions bin/confluence.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const { readStdin } = require('../lib/stdin-utils');
const { emitJson, emitJsonError, jsonRequested, setJsonMode } = require('../lib/output');
const { fingerprintCopyPlan } = require('../lib/pi/copy-plan');
const { VALID_PLANTUML_FORMATS, normalizePlantumlFormat } = require('../lib/plantuml-format');
const { parseFrontMatter, prependFrontMatter, parsePropertyKeys } = require('../lib/front-matter');

const READ_ONLY_MESSAGE = 'This profile is in read-only mode. Write operations are not allowed.';
const READ_ONLY_TIP = 'Tip: Use "confluence profile add <name>" without --read-only, or set readOnly to false in config.';
Expand Down Expand Up @@ -59,6 +60,82 @@ function resolveCliPlantumlFormat(options, { readEnv = false } = {}) {

const PLANTUML_FORMAT_OPTION = ['--plantuml-format <format>', `PlantUML macro format (${VALID_PLANTUML_FORMATS.join(', ')})`];

const FRONT_MATTER_WRITE_OPTION = ['--front-matter', 'Apply the "properties" map from YAML front matter as content properties (requires --format markdown)'];

class PropertySyncError extends Error {
constructor(message, syncResult) {
super(message);
this.syncResult = syncResult;
}
}

// With --front-matter, strip the front matter from the body and return its
// properties. Runs before any network call so invalid YAML uploads nothing.
// Without the flag, content is passed through untouched (a leading `---` stays
// a thematic break).
function extractFrontMatter(content, options) {
if (!options.frontMatter) {
return { content, properties: null, present: false };
}
if (options.type === 'folder') {
throw new Error('--front-matter is not allowed with --type folder (folders have no body).');
}
if (String(options.format).trim().toLowerCase() !== 'markdown') {
throw new Error('--front-matter requires --format markdown.');
}
if (content === null || content === undefined) {
throw new Error('--front-matter requires --file or --content.');
}
const { properties, body, present } = parseFrontMatter(content);
if (present && !properties) {
console.error(chalk.yellow('Warning: front matter has no "properties" map; no content properties will be set.'));
}
return { content: body, properties: properties || {}, present };
}

async function applyFrontMatterProperties(client, pageId, properties) {
if (!properties) {
return null;
}
if (Object.keys(properties).length === 0) {
return { applied: [], unchanged: [], failed: [] };
}
return client.syncProperties(pageId, properties);
}

function printPropertySummary(syncResult) {
if (!syncResult) return;
if (syncResult.applied.length > 0) {
console.log(`Properties set: ${chalk.blue(syncResult.applied.join(', '))}`);
}
if (syncResult.unchanged.length > 0) {
console.log(`Properties unchanged: ${chalk.gray(syncResult.unchanged.join(', '))}`);
}
}

// Re-running create would make another page, so point at update instead.
function createRetryHint(pageId, options) {
const source = options.file ? `--file ${options.file}` : '--file <file>';
return `Retry the properties with "confluence update ${pageId} ${source} --format markdown --front-matter"; ` +
'properties that already match are skipped.';
}

const UPDATE_RETRY_HINT = 'Re-run the same command to retry; properties that already match are skipped.';

// The body is saved before properties, so a property failure is a partial
// result: report it after the success output and exit non-zero.
function assertPropertiesSynced(syncResult, pageId, retryHint) {
if (!syncResult || syncResult.failed.length === 0) return;
const failures = syncResult.failed
.map(({ key, status, error }) => `${key} (${status ? `${status}: ` : ''}${error})`)
.join(', ');
const count = syncResult.failed.length;
throw new PropertySyncError(
`Page ${pageId} was saved, but ${count} content ${count === 1 ? 'property' : 'properties'} failed: ${failures}. ${retryHint}`,
syncResult
);
}

const VALID_TYPES = ['page', 'folder'];

function assertValidType(type) {
Expand Down Expand Up @@ -93,7 +170,11 @@ function handleCommandError(analytics, commandName, error, onExtra = null) {
// the stdout=data / stderr=diagnostics contract) so agents/scripts can parse
// failures. Non-JSON callers keep the exact human-readable prose below.
if (program.opts().json) {
emitJsonError(error);
if (error instanceof PropertySyncError) {
emitJsonError(error, { code: 'PARTIAL_FAILURE', status: null, details: { properties: error.syncResult } });
} else {
emitJsonError(error);
}
process.exit(1);
}
if (error instanceof ReadOnlyError) {
Expand Down Expand Up @@ -226,9 +307,23 @@ program
.command('read <pageId>')
.description('Read a Confluence page by ID or URL')
.option('-f, --format <format>', 'Output format (html, text, storage, markdown)', 'text')
.option('--front-matter <keys>', 'Prepend YAML front matter with these comma-separated content property keys (requires --format markdown)')
.action(withClient('read', async ({ client, analytics }, pageId, options) => {
const content = await client.readPage(pageId, options.format);
console.log(content);
if (options.frontMatter === undefined) {
const content = await client.readPage(pageId, options.format);
console.log(content);
analytics.track('read', true);
return;
}

if (String(options.format).trim().toLowerCase() !== 'markdown') {
throw new Error('--front-matter requires --format markdown.');
}
const keys = parsePropertyKeys(options.frontMatter);
const resolvedId = await client.extractPageId(pageId);
const content = await client.readPage(resolvedId, 'markdown');
const properties = await client.getPropertyValues(resolvedId, keys);
console.log(prependFrontMatter(properties, content));
analytics.track('read', true);
}));

Expand Down Expand Up @@ -384,6 +479,7 @@ program
.option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage')
.option('--type <type>', 'Content type (page, folder)', 'page')
.option(...PLANTUML_FORMAT_OPTION)
.option(...FRONT_MATTER_WRITE_OPTION)
.action(withClient('create', async ({ client, analytics, wantsJson, emitJson }, title, spaceKey, options) => {
assertNonEmpty(title, 'title');
assertNonEmpty(spaceKey, 'spaceKey');
Expand All @@ -404,8 +500,10 @@ program
} else if (options.type !== 'folder') {
throw new Error('Either --file or --content option is required');
}
const { content: body, properties } = extractFrontMatter(content, options);

const result = await client.createPage(title, spaceKey, content, options.format, options.type);
const result = await client.createPage(title, spaceKey, body, options.format, options.type);
const syncResult = await applyFrontMatterProperties(client, result.id, properties);

if (wantsJson()) {
emitJson({
Expand All @@ -414,7 +512,9 @@ program
type: options.type,
spaceKey: result.space.key,
url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`),
...(syncResult && { properties: syncResult }),
});
assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options));
analytics.track('create', true);
return;
}
Expand All @@ -425,6 +525,8 @@ program
console.log(`ID: ${chalk.blue(result.id)}`);
console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`);
printPropertySummary(syncResult);
assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options));

analytics.track('create', true);
}, { writable: true }));
Expand All @@ -438,6 +540,7 @@ program
.option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage')
.option('--type <type>', 'Content type (page, folder)', 'page')
.option(...PLANTUML_FORMAT_OPTION)
.option(...FRONT_MATTER_WRITE_OPTION)
.action(withClient('create_child', async ({ client, analytics, wantsJson, emitJson }, title, parentId, options) => {
assertNonEmpty(title, 'title');
assertNonEmpty(parentId, 'parentId');
Expand All @@ -446,10 +549,6 @@ program
const plantumlFormat = resolveCliPlantumlFormat(options);
if (plantumlFormat) client.setPlantumlFormat(plantumlFormat);

// Get parent page info to get space key
const parentInfo = await client.getPageInfo(parentId);
const spaceKey = parentInfo.space.key;

let content = '';

if (options.file) {
Expand All @@ -462,8 +561,14 @@ program
} else if (options.type !== 'folder') {
throw new Error('Either --file or --content option is required');
}
const { content: body, properties } = extractFrontMatter(content, options);

const result = await client.createChildPage(title, spaceKey, parentId, content, options.format, options.type);
// Get parent page info to get space key
const parentInfo = await client.getPageInfo(parentId);
const spaceKey = parentInfo.space.key;

const result = await client.createChildPage(title, spaceKey, parentId, body, options.format, options.type);
const syncResult = await applyFrontMatterProperties(client, result.id, properties);

if (wantsJson()) {
emitJson({
Expand All @@ -473,7 +578,9 @@ program
parentId,
spaceKey: result.space.key,
url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`),
...(syncResult && { properties: syncResult }),
});
assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options));
analytics.track('create_child', true);
return;
}
Expand All @@ -485,6 +592,8 @@ program
console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`);
console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`);
printPropertySummary(syncResult);
assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options));

analytics.track('create_child', true);
}, { writable: true }));
Expand All @@ -498,6 +607,7 @@ program
.option('-c, --content <content>', 'Page content as string')
.option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage')
.option(...PLANTUML_FORMAT_OPTION)
.option(...FRONT_MATTER_WRITE_OPTION)
.action(withClient('update', async ({ client, analytics, wantsJson, emitJson }, pageId, options) => {
// Check if at least one option is provided
if (!options.title && !options.file && !options.content) {
Expand All @@ -521,16 +631,23 @@ program
} else if (options.content) {
content = options.content;
}
const { content: body, properties, present } = extractFrontMatter(content, options);
if (present && !body.trim()) {
throw new Error('The document has front matter but no body; refusing to replace the page content with an empty body. Use "confluence property-set" to change only properties.');
}

const result = await client.updatePage(pageId, options.title, content, options.format);
const result = await client.updatePage(pageId, options.title, body, options.format);
const syncResult = await applyFrontMatterProperties(client, result.id, properties);

if (wantsJson()) {
emitJson({
id: result.id,
title: result.title,
version: result.version.number,
url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`),
...(syncResult && { properties: syncResult }),
});
assertPropertiesSynced(syncResult, result.id, UPDATE_RETRY_HINT);
analytics.track('update', true);
return;
}
Expand All @@ -540,6 +657,8 @@ program
console.log(`ID: ${chalk.blue(result.id)}`);
console.log(`Version: ${chalk.blue(result.version.number)}`);
console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`);
printPropertySummary(syncResult);
assertPropertiesSynced(syncResult, result.id, UPDATE_RETRY_HINT);

analytics.track('update', true);
}, { writable: true }));
Expand Down
Loading
Loading