diff --git a/README.md b/README.md index 283b48c..5e08ce2 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,19 @@ Image. Supports uploading attachments ## Installation -Install packages +Install from npm: ```sh -pnpm install +npm i -g markdown2conf +mdconf -V ``` -Optionally install executable globally as `mdconf` +Install from source code: ```sh pnpm build pnpm link --global +mdconf -V ``` ## Usage @@ -49,7 +51,7 @@ Create a config file at `~/.config/mdconf.json` ### Convert Markdown to Confluence markup ```sh -pnpm dev test/demo.md +mdconf test/demo.md ``` If `output` is provided, saves the result to a file. Otherwise, prints to stdout. @@ -57,7 +59,7 @@ If `output` is provided, saves the result to a file. Otherwise, prints to stdout ### Extract frontmatter (title, labels) ```sh -pnpm dev frontmatter test/demo.md +mdconf frontmatter test/demo.md ``` ```json @@ -86,7 +88,7 @@ mdconf publish markdown.md -i -m 'message' ### Help ```sh -pnpm dev --help +mdconf --help ``` ```text @@ -96,17 +98,17 @@ Usage: mdconf [options] [command] [output.confluence] Markdown to Confluence Wiki Markup Converter Arguments: - input.md Markdown input file - output.confluence Output file (optional) + input.md Markdown input file + output.confluence Output file (optional) Options: - -V, --version output the version number - -h, --help display help for command + -V, --version output the version number + -h, --help display help for command Commands: - frontmatter Extract frontmatter (title, labels) - publish [options] Convert markdown/markup to storage format and publish to Confluence page - new [options] Create a new Confluence page from markdown with frontmatter + frontmatter Extract frontmatter (id, title, labels) + publish|pub [options] Convert markdown/markup to storage format and publish to Confluence page + new [options] Create a new Confluence page from markdown with frontmatter ``` ## Example diff --git a/api.ts b/api.ts index b2e6a13..aa270db 100644 --- a/api.ts +++ b/api.ts @@ -192,23 +192,34 @@ async function getAttachment(pageId: number): Promise { } async function addAttachment(pageId: number, filePath: string, comment: string): Promise { - const api = `rest/api/content/${pageId}/child/attachment` - const { confluenceToken: token } = await loadConfig() - - const filename = filePath - .split('/') - .pop() - ?.replace(/[^a-zA-Z0-9.-]/g, '_') // Sanitize filename - + const { confluenceToken: token, host } = await loadConfig() + const filename = path.basename(filePath) const fileBuffer = await fs.readFile(filePath) const uint8Array = new Uint8Array(fileBuffer) const blob = new Blob([uint8Array], { type: 'image/png' }) - const formData = new FormData() formData.append('comment', comment) formData.append('file', blob, filename) + formData.append('minorEdit', 'true') - const response = await fetch(api, { + const id = await getAttachmentByName(pageId, filename) + if (id !== null) { + console.log(`Attachment ${filename} already exists. Updating it...`) + const response = await fetch(`${host}/rest/api/content/${pageId}/child/attachment/${id}/data`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'X-Atlassian-Token': 'nocheck', // Attachment upload requires this header + }, + body: formData, + }) + if (!response.ok) { + const text = await response.text() + throw new Error(`updating attachment "${filename}" on page ${pageId}: ${response.status} ${text}`) + } + return response.json() + } + const response = await fetch(`${host}/rest/api/content/${pageId}/child/attachment`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, @@ -218,11 +229,17 @@ async function addAttachment(pageId: number, filePath: string, comment: string): }) if (!response.ok) { const text = await response.text() - throw new Error(`Request failed ${pageId}: ${response.status} ${text}`) + throw new Error(`creating attachment "${filename}" on page ${pageId}: ${response.status} ${text}`) } return response.json() } +async function getAttachmentByName(pageId: number, filename: string): Promise { + const api = `rest/api/content/${pageId}/child/attachment?filename=${encodeURIComponent(filename)}` + const data = await request('GET', api) + return data.results[0]?.id || null +} + async function postLabels(pageId: number, labels: string[]): Promise { const api = `rest/api/content/${pageId}/label` const body = labels.map((label) => ({ prefix: 'global', name: label.toLowerCase() })) @@ -259,6 +276,7 @@ async function syncLabels(pageId: number, labels: string[]): Promise { } } + export { addAttachment, createPage, diff --git a/convert.js b/convert.js index 818f603..b9d7f7b 100644 --- a/convert.js +++ b/convert.js @@ -217,8 +217,9 @@ const confluenceRenderer = { let src = href const isUrl = /^https?:\/\//.test(href) if (!isUrl) { - src = src.split('/').pop().split('?')[0] - localImages.push(href) + // For local images, decode URL encoding and get just the filename for Confluence display + src = decodeURIComponent(src.split('/').pop().split('?')[0]) + localImages.push(href) // Keep original href for file path resolution } if (text && text.trim()) { return `!${src}|alt=${text.trim()}!` diff --git a/index.ts b/index.ts index 5ccdde4..7be4560 100644 --- a/index.ts +++ b/index.ts @@ -5,7 +5,7 @@ import { createPage, homePage, markupToStorage, syncLabels } from './api.js' import { convertToConfluence, extractFrontMatter } from './convert.js' import { inferPageId, mdToStorage, relativePaths, updateConfluencePage, upploadImages } from './main.js' -const VERSION = '1.4.12-rc.1' +const VERSION = '1.4.13-rc.1' async function main() { const program = new Command() @@ -76,7 +76,7 @@ async function main() { await updateConfluencePage({ pageId: pageIdNum, storage, message, labels, title: newTitle }) if (options.attachment && localImages.length > 0) { const relativeImagePaths = relativePaths(filePath, localImages) - upploadImages(pageIdNum, relativeImagePaths) + await upploadImages(pageIdNum, relativeImagePaths) } } } catch (error) { @@ -105,11 +105,11 @@ async function main() { console.log(`Created new Confluence page:\n${result._links.base + result._links.tinyui}`) if (localImages.length > 0) { const relativeImagePaths = relativePaths(filePath, localImages) - upploadImages(result.id, relativeImagePaths) + await upploadImages(result.id, relativeImagePaths) } const attrs = await extractFrontMatter(await fs.readFile(filePath, 'utf-8')) if (attrs.labels && attrs.labels.length > 0) { - syncLabels(result.id, attrs.labels) + await syncLabels(result.id, attrs.labels) } } catch (error) { console.error(`${error.message}`) diff --git a/main.ts b/main.ts index 9c1372b..3df990e 100644 --- a/main.ts +++ b/main.ts @@ -13,22 +13,22 @@ function inferPageId(srcFile: string): number { } interface UpdatePageParams { - pageId: number - storage: string - message: string - labels?: string[] - title?: string + pageId: number + storage: string + message: string + labels?: string[] + title?: string } async function updateConfluencePage(params: UpdatePageParams) { - const { pageId, storage, message, title, labels = [] } = params - const page = await getPage(pageId) - if (labels.length > 0) { - syncLabels(pageId, labels) - } - const pageTitle = title || page.title - const result = await editPage(pageId, storage, pageTitle, page.version + 1, page.space, message) - console.log(`Published to Confluence: version ${result.version.number}\n${page.tinyui}`) + const { pageId, storage, message, title, labels = [] } = params + const page = await getPage(pageId) + if (labels.length > 0) { + await syncLabels(pageId, labels) + } + const pageTitle = title || page.title + const result = await editPage(pageId, storage, pageTitle, page.version + 1, page.space, message) + console.log(`Published to Confluence: version ${result.version.number}\n${page.tinyui}`) } async function mdToStorage(markdownPath: string, options: { title?: string }) { @@ -44,15 +44,20 @@ async function mdToStorage(markdownPath: string, options: { title?: string }) { } async function upploadImages(pageId: number, imagePaths: string[]): Promise { - return imagePaths.map((imagePath) => { - console.log(`Uploading image: ${imagePath}`) - try { - return addAttachment(pageId, imagePath, 'Uploaded by mdconf') - } catch (error) { - console.error(`Failed to upload ${imagePath}: ${error.message}`) - return null - } - }) + const results: any[] = [] + + for (const imagePath of imagePaths) { + console.info(`Uploading image: ${imagePath}`) + try { + const result = await addAttachment(pageId, imagePath, 'Uploaded by mdconf') + results.push(result) + } catch (error) { + console.error(`Failed to upload ${imagePath}: ${error.message}`) + results.push(null) + } + } + + return results } function relativePaths(basePath: string, imagePaths: string[]): string[] { @@ -64,7 +69,7 @@ function relativePaths(basePath: string, imagePaths: string[]): string[] { if (path.isAbsolute(imgPath)) { return imgPath } - return path.resolve(baseDir, imgPath) + return path.resolve(baseDir, decodeURIComponent(imgPath)) }) } diff --git a/package.json b/package.json index 8bd1c40..1f8b463 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markdown2conf", - "version": "1.4.12-rc.1", + "version": "1.4.13-rc.1", "type": "module", "description": "Convert Markdown files to Confluence Wiki Markup and publish to Confluence with CLI", "main": "index.js", diff --git a/test/basic.test.js b/test/basic.test.js index 73a0947..b0e037e 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -53,6 +53,13 @@ h5. heading 5`) expect(got.trim()).toBe(want) }) + it('converts local images with URI encoded paths', async () => { + const md = '![alt text](./image%20name.png)\n![](https://example.org/image%20name.png)' + const { markup: got } = await convertToConfluence(md) + const want = '!image name.png|alt=alt text!\n!https://example.org/image%20name.png!' + expect(got.trim()).toBe(want) + }) + it('converts unordered lists', async () => { const md = '- item1\n- item2' const { markup: conf } = await convertToConfluence(md) diff --git a/test/demo.md b/test/demo.md index 0a20a9c..e1a949f 100644 --- a/test/demo.md +++ b/test/demo.md @@ -29,7 +29,7 @@ New line [#Test Lists](#test-lists) [#Heading-With Dash](#heading-with-dash) -![Example Alt](./example.jpg) +![Example Alt](./example%201.jpg) ## heading 2