From bc3c097e5f27d960b15f73fb6c4beb659c0c5b0e Mon Sep 17 00:00:00 2001 From: Kondo Takeo Date: Fri, 3 Jul 2026 00:44:25 +0900 Subject: [PATCH 1/2] fix: local assets not recognized when page title contains an apostrophe (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When a Confluence page title contained an apostrophe (`'`), draw.io and regular image assets were not recognized as local files and the tool attempted to re-download them as remote URLs, resulting in a warning and a failed fetch: WARN Failed to fetch image: https://example.com/confluence2md's_…_assets%2Fsingle.drawio.png ## Root cause `to_markdown_asset_path` (utils.rs) encodes asset paths using the `URI_COMPONENT` character set, which leaves `'` as a literal character. `is_local_markdown_asset` (confluence.rs) built its comparison prefix using the `PATH_SEGMENT` character set, which encodes `'` as `%27`. The resulting mismatch caused the prefix check to fail for any page title containing an apostrophe, so every asset on such a page was treated as a remote URL. // old: PATH_SEGMENT encodes ' → %27 encoded_prefix = "confluence2md%27s_…_assets" // src produced by to_markdown_asset_path (URI_COMPONENT, ' is literal) src = "confluence2md's_…_assets%2Fsingle.drawio.png" // starts_with check → false → asset downloaded as remote URL ## Fix Change `is_local_markdown_asset` to encode the comparison prefix with `URI_COMPONENT` instead of `PATH_SEGMENT`, matching the encoding used by `to_markdown_asset_path`. Export `URI_COMPONENT` as `pub` from utils.rs and import it in confluence.rs. ## Tests Added regression tests in `confluence::tests`: - `is_local_markdown_asset_recognizes_apostrophe_in_prefix` — verifies that a path generated by `to_markdown_asset_path` with an apostrophe in the prefix is recognized as a local asset (would fail with the old `PATH_SEGMENT`-based code) - `is_local_markdown_asset_recognizes_plain_prefix` — ensures the ordinary case continues to work - `is_local_markdown_asset_rejects_remote_url` — ensures remote URLs are not mistakenly treated as local assets --- src/confluence.rs | 39 ++++++++++++++++++++++++++++++++++++--- src/utils.rs | 2 +- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/confluence.rs b/src/confluence.rs index e0b100e..9bdd5f4 100644 --- a/src/confluence.rs +++ b/src/confluence.rs @@ -15,8 +15,8 @@ use tracing::{debug, warn}; use url::Url; use crate::utils::{ - HeaderHints, decode_html_attribute, ensure_dir, get_file_name_from_url_or_headers, resolve_url, - to_markdown_asset_path, + HeaderHints, URI_COMPONENT, decode_html_attribute, ensure_dir, + get_file_name_from_url_or_headers, resolve_url, to_markdown_asset_path, }; // ── Public types ─────────────────────────────────────────────────── @@ -464,7 +464,7 @@ pub async fn download_images_and_rewrite_html( } fn is_local_markdown_asset(src: &str, markdown_image_prefix: &str) -> bool { - let encoded_prefix = utf8_percent_encode(markdown_image_prefix, PATH_SEGMENT).to_string(); + let encoded_prefix = utf8_percent_encode(markdown_image_prefix, &URI_COMPONENT).to_string(); src.starts_with(&format!("{markdown_image_prefix}/")) || src.starts_with(&format!("{encoded_prefix}%2F")) } @@ -729,4 +729,37 @@ mod tests { assert_eq!(page.export_html, "

export

"); assert_eq!(page.storage_html.as_deref(), Some("

storage

")); } + + // Regression test for: is_local_markdown_asset fails when the markdown_image_prefix + // contains an apostrophe because the old code used PATH_SEGMENT encoding (which + // encodes `'` → `%27`) while to_markdown_asset_path uses URI_COMPONENT encoding + // (which keeps `'` as a literal). The mismatch caused local draw.io / image assets + // to be treated as remote URLs and re-downloaded. + #[test] + fn is_local_markdown_asset_recognizes_apostrophe_in_prefix() { + let prefix = "confluence2md's_test_assets"; + let src = to_markdown_asset_path(prefix, "single.drawio.png"); + // With the old PATH_SEGMENT encoding, encoded_prefix would contain %27 instead + // of the literal apostrophe used by to_markdown_asset_path, so starts_with + // would return false and this assertion would fail. + assert!( + is_local_markdown_asset(&src, prefix), + "local asset not recognized (apostrophe in prefix): {src}" + ); + } + + #[test] + fn is_local_markdown_asset_recognizes_plain_prefix() { + let prefix = "my_page_assets"; + let src = to_markdown_asset_path(prefix, "image.png"); + assert!(is_local_markdown_asset(&src, prefix)); + } + + #[test] + fn is_local_markdown_asset_rejects_remote_url() { + assert!(!is_local_markdown_asset( + "https://example.com/image.png", + "my_page_assets" + )); + } } diff --git a/src/utils.rs b/src/utils.rs index 4b307c9..7da377a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -313,7 +313,7 @@ pub fn make_assets_info(page_id: &str, title: &str, output_path: &Path) -> Asset // `encodeURIComponent` percent-encodes everything that is not in the unreserved // set `A-Z a-z 0-9 - _ . ! ~ * ' ( )`. Construct the inverse `AsciiSet`. -const URI_COMPONENT: AsciiSet = NON_ALPHANUMERIC +pub const URI_COMPONENT: AsciiSet = NON_ALPHANUMERIC .remove(b'-') .remove(b'_') .remove(b'.') From a6ef047e0196d54b9084c8674f6eb1eeebb9248c Mon Sep 17 00:00:00 2001 From: Kondo Takeo Date: Fri, 3 Jul 2026 00:49:45 +0900 Subject: [PATCH 2/2] fix: correctly rewrite img src when URL contains an apostrophe (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Confluence page title contains an apostrophe (e.g. "My team's page"), the Confluence REST API renders image `src` attributes using single-quoted HTML attribute values (e.g. `src='…/My%20team%27s%20page/image.png'`). The previous regex used a back-reference approach (`["']` open + `["']` close) that verified the opening and closing quotes matched. However, if the captured src string itself contained a single-quote character the regex engine would stop the lazy `.*?` match early—at the embedded quote—causing the closing-quote group to see a mismatch and skip the rewrite entirely. As a result the local asset path was never substituted and the generated Markdown still pointed at the remote Confluence URL. Fix: rewrite the regex to use two independent alternating groups (`"([^"]*)"` and `'([^']*)'`) so each branch only matches content that cannot contain its own delimiter. The replacement logic picks the correct quote character from whichever group matched, making the rewrite robust regardless of which delimiter Confluence chose for the attribute. Also adds a regression test that mocks a Confluence image endpoint whose path contains a percent-encoded apostrophe and asserts the resulting HTML has the src rewritten to a local asset path. --- src/confluence.rs | 62 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/src/confluence.rs b/src/confluence.rs index 9bdd5f4..a44eae2 100644 --- a/src/confluence.rs +++ b/src/confluence.rs @@ -444,18 +444,19 @@ pub async fn download_images_and_rewrite_html( } static REPLACE_RE: Lazy = - Lazy::new(|| Regex::new(r#"(?is)(]*\bsrc=)(["'])(.*?)(["'])"#).unwrap()); + Lazy::new(|| Regex::new(r#"(?is)(]*\bsrc=)(?:"([^"]*)"|'([^']*)')"#).unwrap()); let result = REPLACE_RE.replace_all(html, |caps: ®ex::Captures<'_>| { let prefix = &caps[1]; - let quote_open = &caps[2]; - let src = &caps[3]; - let quote_close = &caps[4]; - if quote_open != quote_close { + let (quote, src) = if let Some(m) = caps.get(2) { + ('"', m.as_str()) + } else if let Some(m) = caps.get(3) { + ('\'', m.as_str()) + } else { return caps[0].to_owned(); - } + }; match src_to_local.get(src) { - Some(local) => format!("{prefix}{quote_open}{local}{quote_close}"), + Some(local) => format!("{prefix}{quote}{local}{quote}"), None => caps[0].to_owned(), } }); @@ -708,6 +709,53 @@ mod tests { assert_eq!(id, "777888"); } + #[tokio::test] + async fn rewrite_html_rewrites_image_src_when_url_contains_apostrophe() { + let server = MockServer::start().await; + // Confluence embeds the raw page title (with apostrophe) in the URL. + let img_path = "/download/attachments/123/My%20team's%20page/image.png"; + Mock::given(method("GET")) + .and(path(img_path)) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"\x89PNG\r\n\x1a\n")) + .mount(&server) + .await; + + let html = format!(r#""#, server.uri(), img_path); + + let tmp_dir = std::env::temp_dir().join(format!( + "confluence2md_test_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + let assets_dir = tmp_dir.join("assets"); + let client = Client::new(); + let mut used = HashSet::new(); + let result = download_images_and_rewrite_html( + &client, + &html, + DownloadImagesOptions { + base_url: &server.uri(), + personal_access_token: "token", + assets_abs_dir: &assets_dir, + markdown_image_prefix: "assets", + used_names: &mut used, + }, + ) + .await + .unwrap(); + + assert!( + !result.contains(&server.uri()), + "src should be rewritten to local path, got: {result}" + ); + assert!( + result.contains("assets"), + "src should point into assets dir, got: {result}" + ); + } + #[tokio::test] async fn fetch_confluence_page_preserves_content_json_response() { let server = MockServer::start().await;