diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..b630c81 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[env] +# confluence2md tests do not support multi-threading due to environment variable sharing between test cases. +RUST_TEST_THREADS = "1" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e925dec..df8e6ff 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -57,7 +57,7 @@ flowchart TD E --> F[confluence::download_images_and_rewrite_html
regular img src → local assets] F --> G[plantuml::resolve_plantuml_diagrams
imgs → fenced plantuml blocks] G --> H[utils::preprocess_confluence_macros
code/expand/jira/lref/alerts] - H --> I[html::convert_to_md
TOC link rewrite + htmd + custom plugins] + H --> I[html::convert_html_to_markdown
TOC link rewrite + htmd + custom plugins] I --> J[Write Page_Title.md + assets] ``` @@ -70,7 +70,13 @@ confluence2md is a single Rust crate that exposes one binary (`confluence2md`) a ### 3.1. `main.rs` — CLI Entry Point - **Responsibility:** Parse CLI flags (`clap` derive) and environment variables, set up logging, and orchestrate the conversion pipeline end-to-end. -- **Key items:** `Cli` struct, `run()` async function (Tokio multi-thread runtime). +- **Key items:** + - `Cli` struct — `clap` derive struct for all flags and positional arguments. + - `ResolvedConfig` struct — holds fully resolved configuration after merging CLI args and `CONFLUENCE2MD_*` environment variables. + - `resolve_config(cli: &Cli) -> Result` — resolves all configuration (paths, log level, table conversion mode, strikethrough option) from `Cli` and environment variables; also initializes the logger as a side effect. + - `extract_base_url(page_url: &str) -> Result` — parses the page URL and returns the normalized scheme + host + port base URL. + - `append_markdown_header(title, page_id, webui, body) -> String` — assembles the final Markdown document from its components (front-matter header and converted body). + - `run()` async function — top-level pipeline orchestrator (Tokio multi-thread runtime). - **Inputs:** `` arg, `--output-path`, `--log-level`, `--table-conversion`, plus `CONFLUENCE2MD_*` env vars. - **Outputs:** `Page_Title.md`, `Page_Title_assets/`. When `--dump-state-path ` or `CONFLUENCE2MD_DUMP_STATE_PATH` is specified, the raw page API snapshot (`content.json`), debug intermediates (`export.html`, `storage.html`, `rewrite_*.html`), and raw draw.io XML files (`*.drawio`) are written to that dump directory. @@ -104,9 +110,9 @@ confluence2md is a single Rust crate that exposes one binary (`confluence2md`) a - **Responsibility:** Convert the rewritten Confluence HTML into GitHub-Flavored Markdown. - **Key types:** `TableConversion` (`Default` | `Always`), `ConvertOptions`. -- **Key function:** `convert_to_md`. +- **Key function:** `convert_html_to_markdown`. - **Built on:** [`htmd`](https://crates.io/crates/htmd) and [`markup5ever_rcdom`](https://crates.io/crates/markup5ever_rcdom), with custom plugins for Confluence alert/expand/code blocks and table preprocessing. -- **TOC behavior:** Before Markdown conversion, `convert_to_md` maps Confluence heading `id` attributes to the Markdown heading slugs generated from the heading text, then rewrites internal `` links that point at those headings. This keeps Confluence TOC macro output navigable in Markdown without adding raw `` anchors to the document. +- **TOC behavior:** Before Markdown conversion, `convert_html_to_markdown` maps Confluence heading `id` attributes to the Markdown heading slugs generated from the heading text, then rewrites internal `` links that point at those headings. This keeps Confluence TOC macro output navigable in Markdown without adding raw `` anchors to the document. - **Table-conversion behavior:** - Both modes unwrap 1x1 tables before normal table conversion: the single cell's content is emitted as regular Markdown outside a table. This keeps block content such as PlantUML fences readable instead of forcing it into a one-cell GFM table. - `Default`: Markdown-compatible tables are converted to GFM; tables without `` have their first row promoted to a header. Tables with merged cells (`colspan`/`rowspan`) or nested tables are preserved as readable, pretty-printed HTML. @@ -171,7 +177,7 @@ sequenceDiagram Plant-->>CLI: HTML with fenced plantuml blocks CLI->>Utils: preprocess_confluence_macros Utils-->>CLI: HTML with code/expand/alert/lref rewritten - CLI->>Conv: convert_to_md + CLI->>Conv: convert_html_to_markdown Conv-->>CLI: Markdown body CLI->>FS: write Page_Title.md ``` @@ -180,21 +186,21 @@ sequenceDiagram confluence2md supports a fixed set of Confluence macros. Each is detected and rewritten by a specific stage of the pipeline. The two columns below show *where* in the pipeline the macro is consumed and *what* the resulting Markdown looks like. -| Macro | Stage (module::function) | Resulting Markdown / HTML | -| ------------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `drawio` | `drawio::resolve_drawio_diagrams` | `![alt](assets/.drawio.png)` — PNG with `.drawio` XML in `tEXt`. Native page draw.io macros and rendered draw.io images inside included content are resolved through the same asset pipeline. | -| `plantuml` | `plantuml::resolve_plantuml_diagrams` | Fenced code block: ` ```plantuml ... ``` ` | -| `code` | `utils::preprocess_confluence_macros` (`code`) | Fenced code block with optional language: ` ```c++ ... ``` ` | -| `expand` | `utils::preprocess_confluence_macros` (expand) | `
Title ...
` | -| `jira` | `jira::replace_jira_macros` | Simple issue link: `[DEMO-1234](https://jira.example.com/browse/DEMO-1234)`. Storage macros derive the browse URL from rendered Jira links when available; otherwise they emit plain key text. | -| `lref-gdrive-file` | `utils::preprocess_confluence_macros` (gdrive) | `[Google Drive Link](https://docs.google.com/...)` | -| `info` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!IMPORTANT]` alert block | -| `panel` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!NOTE]` alert block | -| `tip` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!TIP]` alert block | -| `note` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!WARNING]` alert block | -| `warning` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!CAUTION]` alert block | -| (regular ``) | `confluence::download_images_and_rewrite_html` | `![alt](assets/.)` | -| (tables) | `html::convert_to_md` (`TableConversion`) | 1x1 tables are unwrapped to their single cell content. `Default`: Markdown-compatible tables become GFM; merged/nested tables are preserved as pretty-printed HTML. `Always`: merged cells expanded to flat GFM tables; nested tables extracted after the outer table with unique markers (`(*n)`). | +| Macro | Stage (module::function) | Resulting Markdown / HTML | +| ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `drawio` | `drawio::resolve_drawio_diagrams` | `![alt](assets/.drawio.png)` — PNG with `.drawio` XML in `tEXt`. Native page draw.io macros and rendered draw.io images inside included content are resolved through the same asset pipeline. | +| `plantuml` | `plantuml::resolve_plantuml_diagrams` | Fenced code block: ` ```plantuml ... ``` ` | +| `code` | `utils::preprocess_confluence_macros` (`code`) | Fenced code block with optional language: ` ```c++ ... ``` ` | +| `expand` | `utils::preprocess_confluence_macros` (expand) | `
Title ...
` | +| `jira` | `jira::replace_jira_macros` | Simple issue link: `[DEMO-1234](https://jira.example.com/browse/DEMO-1234)`. Storage macros derive the browse URL from rendered Jira links when available; otherwise they emit plain key text. | +| `lref-gdrive-file` | `utils::preprocess_confluence_macros` (gdrive) | `[Google Drive Link](https://docs.google.com/...)` | +| `info` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!IMPORTANT]` alert block | +| `panel` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!NOTE]` alert block | +| `tip` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!TIP]` alert block | +| `note` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!WARNING]` alert block | +| `warning` | `utils::preprocess_confluence_macros` (alerts) | GitHub `[!CAUTION]` alert block | +| (regular ``) | `confluence::download_images_and_rewrite_html` | `![alt](assets/.)` | +| (tables) | `html::convert_html_to_markdown` (`TableConversion`) | 1x1 tables are unwrapped to their single cell content. `Default`: Markdown-compatible tables become GFM; merged/nested tables are preserved as pretty-printed HTML. `Always`: merged cells expanded to flat GFM tables; nested tables extracted after the outer table with unique markers (`(*n)`). | #### 4.2.1. draw.io flow @@ -250,7 +256,7 @@ flowchart LR F --> G[HTML out] ``` -The alert `
`s use the same class names the Confluence export view would emit; `html::convert_to_md` then maps those classes to GitHub `[!IMPORTANT|NOTE|TIP|WARNING|CAUTION]` blocks during the final Markdown conversion. +The alert `
`s use the same class names the Confluence export view would emit; `html::convert_html_to_markdown` then maps those classes to GitHub `[!IMPORTANT|NOTE|TIP|WARNING|CAUTION]` blocks during the final Markdown conversion. Jira storage macros do not carry a browse URL. `jira::replace_jira_macros` derives the browse base from rendered Jira issue links already present in the REST response, avoiding any hardcoded Jira instance. Rendered issue spans are normalized to only the issue-key link so placeholder summary/status text is not emitted to Markdown. diff --git a/README.md b/README.md index 0afafd6..bb4bc8e 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Set the following environment variables before running: | `CONFLUENCE2MD_DUMP_STATE_PATH` | Directory to write diagnostic state and raw intermediate files | `dumps` | | `CONFLUENCE2MD_LOG_LEVEL` | Log verbosity: `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` (default: `INFO`) | `DEBUG` | | `CONFLUENCE2MD_TABLE_CONVERSION` | Table conversion mode: `default` \| `always` (default: `default`) | `always` | +| `CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT` | Set to `true` to remove strikethrough text entirely | `true` | You can export them in your shell profile or pass them inline: @@ -97,18 +98,20 @@ confluence2md 'https://confluence.example.com/pages/viewpage.action?pageId=39322 ### Options -| Option | Description | Default | -| --------------------------- | -------------------------------------------------------------------------------- | ----------------- | -| `--output-path ` | Directory to write the output Markdown file | Current directory | -| `--dump-state-path ` | Directory to write raw API, intermediate HTML dumps, and raw `.drawio` XML files | Not written | -| `--log-level ` | Log verbosity: `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | `INFO` | -| `--table-conversion ` | Table conversion mode: `default` \| `always` | `default` | -| `--version` | Print the version and exit | — | +| Option | Description | Default | +| ------------------------------- | -------------------------------------------------------------------------------- | ----------------- | +| `--output-path ` | Directory to write the output Markdown file | Current directory | +| `--dump-state-path ` | Directory to write raw API, intermediate HTML dumps, and raw `.drawio` XML files | Not written | +| `--log-level ` | Log verbosity: `DEBUG` \| `INFO` \| `WARNING` \| `ERROR` | `INFO` | +| `--table-conversion ` | Table conversion mode: `default` \| `always` | `default` | +| `--remove-strikethrough-text[=true|false]` | Remove strikethrough text entirely instead of converting to `~~text~~`. Omitting the value implies `true`. When passing a value, the equals form is required (e.g. `--remove-strikethrough-text=false`). | Off | +| `--version` | Print the version and exit | — | > 💡 `--output-path` takes precedence over `CONFLUENCE2MD_OUTPUT_PATH`. > 💡 `--dump-state-path` takes precedence over `CONFLUENCE2MD_DUMP_STATE_PATH`. > 💡 `--log-level` takes precedence over `CONFLUENCE2MD_LOG_LEVEL`. > 💡 `--table-conversion` takes precedence over `CONFLUENCE2MD_TABLE_CONVERSION`. +> 💡 `--remove-strikethrough-text` takes precedence over `CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT`. #### Table conversion modes diff --git a/src/html.rs b/src/html.rs index 1e10f10..eee006e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -56,25 +56,29 @@ pub enum TableConversion { #[derive(Debug, Clone, Default)] pub struct ConvertOptions { pub table_conversion: TableConversion, + /// When `true`, strikethrough text (``, ``, and + /// ``) is removed entirely + /// instead of being converted to `~~text~~`. + pub remove_strikethrough_text: bool, } /// Convert `html` to GitHub-flavoured Markdown with Confluence-specific /// extensions. -pub fn convert_to_md(html: &str, options: ConvertOptions) -> String { +pub fn convert_html_to_markdown(html: &str, options: ConvertOptions) -> String { let heading_links_rewritten = rewrite_internal_heading_links(html); match options.table_conversion { TableConversion::Default => { let promoted = promote_markdown_compatible_tables_to_thead(&heading_links_rewritten); - let converter = build_converter(true); + let converter = build_converter(&options); let md = converter.convert(&promoted).unwrap_or_default(); post_process(&md) } TableConversion::Always => { let (nested_processed, nested_replacements) = - preprocess_nested_tables_always(&heading_links_rewritten); + preprocess_nested_tables_always(&heading_links_rewritten, &options); let expanded = expand_merged_cells(&nested_processed); let promoted = promote_first_row_to_thead(&expanded); - let converter = build_converter(false); + let converter = build_converter(&options); let mut md = post_process(&converter.convert(&promoted).unwrap_or_default()); for (token, replacement) in nested_replacements { md = md.replace(&token, &replacement); @@ -86,7 +90,8 @@ pub fn convert_to_md(html: &str, options: ConvertOptions) -> String { // ── Converter setup ──────────────────────────────────────────────── -fn build_converter(preserve_merged_tables: bool) -> HtmlToMarkdown { +fn build_converter(options: &ConvertOptions) -> HtmlToMarkdown { + let remove_strikethrough_text = options.remove_strikethrough_text; let mut builder = HtmlToMarkdown::builder() .options(Options { bullet_list_marker: BulletListMarker::Dash, @@ -98,9 +103,20 @@ fn build_converter(preserve_merged_tables: bool) -> HtmlToMarkdown { .add_handler(vec!["pre"], pre_tag_handler) .add_handler(vec!["details"], details_tag_handler) .add_handler(vec!["summary"], summary_tag_handler) - .add_handler(vec!["span"], span_tag_handler) - .add_handler(vec!["s", "del"], strikethrough_tag_handler); + .add_handler( + vec!["span"], + move |handlers: &dyn Handlers, element: Element| -> Option { + span_tag_handler(handlers, element, remove_strikethrough_text) + }, + ) + .add_handler( + vec!["s", "del"], + move |handlers: &dyn Handlers, element: Element| -> Option { + strikethrough_tag_handler(handlers, element, remove_strikethrough_text) + }, + ); + let preserve_merged_tables = options.table_conversion == TableConversion::Default; if preserve_merged_tables { builder = builder.add_handler(vec!["table"], table_tag_handler_preserve_merged); } else { @@ -111,16 +127,32 @@ fn build_converter(preserve_merged_tables: bool) -> HtmlToMarkdown { // ── Handlers ─────────────────────────────────────────────────────── -fn strikethrough_tag_handler(handlers: &dyn Handlers, element: Element) -> Option { +fn strikethrough_tag_handler( + handlers: &dyn Handlers, + element: Element, + remove_strikethrough_text: bool, +) -> Option { + if remove_strikethrough_text { + return Some(String::new().into()); + } + let content = handlers.walk_children(element.node).content; Some(format!("~~{content}~~").into()) } -fn span_tag_handler(handlers: &dyn Handlers, element: Element) -> Option { +fn span_tag_handler( + handlers: &dyn Handlers, + element: Element, + remove_strikethrough_text: bool, +) -> Option { let content = handlers.walk_children(element.node).content; if span_has_style_text_decoration_line_through(element) { - return Some(format!("~~{content}~~").into()); + return if remove_strikethrough_text { + Some(String::new().into()) + } else { + Some(format!("~~{content}~~").into()) + }; } // Default for plain : walk children transparently. @@ -743,10 +775,10 @@ fn extract_nested_tables_from_table( (outer, extracted) } -fn convert_table_fragment_always(table_html: &str) -> String { +fn convert_table_fragment_always(table_html: &str, options: &ConvertOptions) -> String { let expanded = expand_merged_cells(table_html); let promoted = promote_first_row_to_thead(&expanded); - let converter = build_converter(false); + let converter = build_converter(options); post_process(&converter.convert(&promoted).unwrap_or_default()) .trim() .to_string() @@ -757,7 +789,10 @@ fn convert_table_fragment_always(table_html: &str) -> String { /// /// 1. Outer table markdown (nested cells replaced by markers) /// 2. Extracted nested table markdowns appended after the outer table -fn preprocess_nested_tables_always(html: &str) -> (String, Vec<(String, String)>) { +fn preprocess_nested_tables_always( + html: &str, + options: &ConvertOptions, +) -> (String, Vec<(String, String)>) { let ranges = find_top_level_table_ranges(html); if ranges.is_empty() { return (html.to_string(), Vec::new()); @@ -779,9 +814,9 @@ fn preprocess_nested_tables_always(html: &str) -> (String, Vec<(String, String)> let (outer, extracted) = extract_nested_tables_from_table(table_html, &mut marker_counter); - let mut snippet = convert_table_fragment_always(&outer); + let mut snippet = convert_table_fragment_always(&outer, options); for (marker, nested_table_html) in extracted { - let nested_md = convert_table_fragment_always(&nested_table_html); + let nested_md = convert_table_fragment_always(&nested_table_html, options); snippet.push_str("\n\n"); snippet.push_str(&marker); snippet.push_str("\n\n"); @@ -1062,14 +1097,15 @@ mod tests { use super::*; fn td(html: &str) -> String { - convert_to_md(html, ConvertOptions::default()) + convert_html_to_markdown(html, ConvertOptions::default()) } fn td_always(html: &str) -> String { - convert_to_md( + convert_html_to_markdown( html, ConvertOptions { table_conversion: TableConversion::Always, + ..ConvertOptions::default() }, ) } @@ -1391,4 +1427,40 @@ A -> B let actual = td("strikethrough"); assert_eq!(actual, "~~strikethrough~~\n"); } + + fn td_remove_st(html: &str) -> String { + convert_html_to_markdown( + html, + ConvertOptions { + remove_strikethrough_text: true, + ..ConvertOptions::default() + }, + ) + } + + #[test] + fn it_should_remove_strikethrough_when_option_enabled() { + // tag + assert_eq!(td_remove_st("gone"), "\n"); + assert_eq!(td_remove_st("keep gone keep"), "keep keep\n"); + + // tag + assert_eq!(td_remove_st("gone"), "\n"); + + // + assert_eq!( + td_remove_st("gone"), + "\n" + ); + assert_eq!( + td_remove_st("keep gone keep"), + "keep keep\n" + ); + + // Non-strikethrough span is unaffected + assert_eq!( + td_remove_st("visible"), + "visible\n" + ); + } } diff --git a/src/main.rs b/src/main.rs index f97b2f3..10075dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use std::path::PathBuf; use anyhow::{Context, Result}; -use clap::Parser; +use clap::{CommandFactory, Parser, builder::TypedValueParser}; use confluence2md::confluence::{ DownloadImagesOptions, build_attachment_maps, build_http_client, @@ -12,14 +12,14 @@ use confluence2md::confluence::{ resolve_page_id_from_url, }; use confluence2md::drawio::{ResolveDrawioOptions, resolve_drawio_diagrams}; -use confluence2md::html::{ConvertOptions, TableConversion, convert_to_md}; +use confluence2md::html::{ConvertOptions, TableConversion, convert_html_to_markdown}; use confluence2md::logger::{self, parse_log_level}; use confluence2md::plantuml::{ResolvePlantUmlOptions, resolve_plantuml_diagrams}; use confluence2md::utils::{ apply_task_list_statuses, ensure_dir, make_assets_info, normalize_base_url, preprocess_confluence_macros, sanitize_file_name, }; -use tracing::{debug, error, info}; +use tracing::{debug, info}; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -36,6 +36,8 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); " CONFLUENCE2MD_DUMP_STATE_PATH dump-state directory (overridden by --dump-state-path)\n", " CONFLUENCE2MD_LOG_LEVEL log level (overridden by --log-level)\n", " CONFLUENCE2MD_TABLE_CONVERSION table conversion mode (overridden by --table-conversion)\n", + " CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT set to \"true\" to remove strikethrough text\n", + " (overridden by --remove-strikethrough-text)\n", "\n", "Example:\n", " CONFLUENCE2MD_PERSONAL_ACCESS_TOKEN=\"xxx\" \\\n", @@ -60,31 +62,43 @@ struct Cli { #[arg(long = "table-conversion", value_name = "MODE")] table_conversion: Option, + /// Remove strikethrough text entirely instead of converting to ~~text~~. + /// Accepts an optional value (true/false). Omitting the value implies true. + /// When passing a value, the equals form is required (e.g. `--remove-strikethrough-text=false`); + /// a space-separated value like `--remove-strikethrough-text false` is not supported. + #[arg( + long = "remove-strikethrough-text", + value_parser = clap::builder::BoolishValueParser::new(), + num_args = 0..=1, + default_missing_value = "true", + require_equals = true + )] + remove_strikethrough_text: Option, + /// Confluence page URL. page_url: Option, } -#[tokio::main] -async fn main() { - if let Err(err) = run().await { - error!("{err:#}"); - std::process::exit(1); - } +/// Resolved configuration from CLI arguments and environment variables. +#[derive(Debug)] +struct ResolvedConfig { + page_url: String, + output_dir: PathBuf, + dump_state_dir: Option, + log_level: logger::LogLevel, + table_conversion: TableConversion, + remove_strikethrough_text: bool, } -async fn run() -> Result<()> { - let cli = Cli::parse(); - - let level = if let Some(level_str) = cli +/// Resolves all configuration from CLI arguments and environment variables. +fn resolve_config(cli: &Cli) -> Result { + let log_level = cli .log_level .clone() .or_else(|| std::env::var("CONFLUENCE2MD_LOG_LEVEL").ok()) - { - parse_log_level(&level_str).context("parsing log level")? - } else { - logger::LogLevel::Info - }; - logger::init(level); + .map(|s| parse_log_level(&s).context("parsing log level")) + .transpose()? + .unwrap_or(logger::LogLevel::Info); let table_mode_str = cli .table_conversion @@ -103,7 +117,7 @@ async fn run() -> Result<()> { anyhow::anyhow!("Missing required argument. Use --help for usage.") })?; - let output_dir_input = cli + let output_dir = cli .output_path .clone() .or_else(|| { @@ -112,7 +126,7 @@ async fn run() -> Result<()> { .map(PathBuf::from) }) .unwrap_or_else(|| PathBuf::from(".")); - let output_dir = absolutize_path(output_dir_input)?; + let dump_state_dir = cli .dump_state_path .clone() @@ -124,50 +138,101 @@ async fn run() -> Result<()> { .map(absolutize_path) .transpose()?; - let parsed_url = url::Url::parse(&page_url).context("Invalid page URL")?; + let remove_strikethrough_text = cli + .remove_strikethrough_text + .map(Ok) + .or_else(|| { + std::env::var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") + .ok() + .map(|value| { + clap::builder::BoolishValueParser::new() + .parse_ref(&Cli::command(), None, std::ffi::OsStr::new(&value)) + .map_err(|_| { + anyhow::anyhow!( + "Invalid CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT value: \"{value}\". \ + Must be \"true\", \"false\", \"1\", \"0\", \"yes\", \"no\", \"on\", or \"off\"." + ) + }) + }) + }) + .transpose()? + .unwrap_or(false); + + Ok(ResolvedConfig { + page_url, + output_dir: absolutize_path(output_dir)?, + dump_state_dir, + log_level, + table_conversion, + remove_strikethrough_text, + }) +} + +/// Extracts and normalizes the base URL (scheme + host + optional port) from a page URL. +fn extract_base_url(page_url: &str) -> Result { + let parsed = url::Url::parse(page_url).context("Invalid page URL")?; let origin = format!( "{}://{}{}", - parsed_url.scheme(), - parsed_url.host_str().unwrap_or(""), - parsed_url - .port() - .map(|p| format!(":{p}")) - .unwrap_or_default() + parsed.scheme(), + parsed.host_str().unwrap_or(""), + parsed.port().map(|p| format!(":{p}")).unwrap_or_default() ); - let base_url = normalize_base_url(&origin); - let env = get_required_env()?; - let token = env.personal_access_token; + Ok(normalize_base_url(&origin)) +} + +fn append_markdown_header(title: &str, page_id: &str, webui: Option<&str>, body: &str) -> String { + let mut markdown = format!("# {title}\n\n- Confluence Page ID: {page_id}\n"); + if let Some(url) = webui { + markdown.push_str(&format!("- URL: {url}\n")); + } + markdown.push_str("\n---\n\n"); + markdown.push_str(body); + markdown.push('\n'); + markdown +} +#[tokio::main] +async fn main() { + if let Err(err) = run().await { + eprintln!("Error: {err:#}"); + std::process::exit(1); + } +} + +async fn run() -> Result<()> { + let cli = Cli::parse(); + let config = resolve_config(&cli)?; + logger::init(config.log_level); + + let base_url = extract_base_url(&config.page_url)?; + let token = get_required_env()?.personal_access_token; let client = build_http_client()?; - let page_id = match resolve_page_id_from_url(&client, &page_url, &base_url, &token).await { - Ok(id) => id, - Err(err) => { - error!("failed to resolve page ID from URL: {err}"); - std::process::exit(1); - } - }; - debug!("Resolved page ID for \"{page_url}\": {page_id}"); + let page_id = resolve_page_id_from_url(&client, &config.page_url, &base_url, &token) + .await + .context("failed to resolve page ID from URL")?; + debug!("Resolved page ID for \"{}\": {page_id}", config.page_url); - ensure_dir(&output_dir).await?; - if let Some(dir) = &dump_state_dir { + ensure_dir(&config.output_dir).await?; + if let Some(dir) = &config.dump_state_dir { ensure_dir(dir).await?; } let page = fetch_confluence_page(&client, &page_id, &base_url, &token).await?; - write_dump_state(&dump_state_dir, "content.json", &page.content_json).await?; + write_dump_state(&config.dump_state_dir, "content.json", &page.content_json).await?; let title = if page.title.is_empty() { format!("page-{page_id}") } else { page.title.clone() }; - let output_file_name = format!("{}.md", sanitize_file_name(&title)); - let output_path = output_dir.join(&output_file_name); + let output_path = config + .output_dir + .join(format!("{}.md", sanitize_file_name(&title))); - write_dump_state(&dump_state_dir, "export.html", &page.export_html).await?; + write_dump_state(&config.dump_state_dir, "export.html", &page.export_html).await?; write_dump_state( - &dump_state_dir, + &config.dump_state_dir, "storage.html", page.storage_html.as_deref().unwrap_or(""), ) @@ -176,11 +241,10 @@ async fn run() -> Result<()> { let attachments = list_attachments(&client, &page_id, &base_url, &token).await?; let maps = build_attachment_maps(&attachments); - let mut html_for_markdown: String = page.export_html.clone(); - let assets_info = make_assets_info(&page_id, &title, &output_path); ensure_dir(&assets_info.assets_abs_dir).await?; + let mut html = page.export_html.clone(); let mut used_names: HashSet = HashSet::new(); let drawio_result = resolve_drawio_diagrams( @@ -188,23 +252,23 @@ async fn run() -> Result<()> { ResolveDrawioOptions { page_id: &page_id, storage_html: page.storage_html.as_deref(), - export_html: &html_for_markdown, + export_html: &html, attachments_by_title: &maps.by_title, base_url: &base_url, token: &token, assets_abs_dir: &assets_info.assets_abs_dir, - dump_state_abs_dir: dump_state_dir.as_deref(), + dump_state_abs_dir: config.dump_state_dir.as_deref(), markdown_image_prefix: &assets_info.markdown_image_prefix, used_names: &mut used_names, }, ) .await?; - html_for_markdown = drawio_result.0; - write_dump_state(&dump_state_dir, "rewrite_drawio.html", &html_for_markdown).await?; + html = drawio_result.0; + write_dump_state(&config.dump_state_dir, "rewrite_drawio.html", &html).await?; - html_for_markdown = download_images_and_rewrite_html( + html = download_images_and_rewrite_html( &client, - &html_for_markdown, + &html, DownloadImagesOptions { base_url: &base_url, personal_access_token: &token, @@ -214,14 +278,14 @@ async fn run() -> Result<()> { }, ) .await?; - write_dump_state(&dump_state_dir, "rewrite_image.html", &html_for_markdown).await?; + write_dump_state(&config.dump_state_dir, "rewrite_image.html", &html).await?; let plantuml_result = resolve_plantuml_diagrams( &client, ResolvePlantUmlOptions { page_id: &page_id, storage_html: page.storage_html.as_deref(), - html: &html_for_markdown, + html: &html, attachments_by_title: &maps.by_title, base_url: &base_url, token: &token, @@ -231,34 +295,28 @@ async fn run() -> Result<()> { }, ) .await?; - html_for_markdown = plantuml_result.0; - write_dump_state(&dump_state_dir, "rewrite_plantuml.html", &html_for_markdown).await?; + html = plantuml_result.0; + write_dump_state(&config.dump_state_dir, "rewrite_plantuml.html", &html).await?; if let Some(storage_html) = page.storage_html.as_deref() { - html_for_markdown = apply_task_list_statuses(&html_for_markdown, storage_html); - } - html_for_markdown = preprocess_confluence_macros(&html_for_markdown); - write_dump_state(&dump_state_dir, "rewrite_macros.html", &html_for_markdown).await?; - - let markdown_body = convert_to_md(&html_for_markdown, ConvertOptions { table_conversion }); - - let mut markdown = String::new(); - markdown.push_str(&format!("# {title}\n")); - markdown.push('\n'); - markdown.push_str(&format!("- Confluence Page ID: {page_id}\n")); - if let Some(webui) = &page.webui { - markdown.push_str(&format!("- URL: {webui}\n")); + html = apply_task_list_statuses(&html, storage_html); } - markdown.push('\n'); - markdown.push_str("---\n"); - markdown.push('\n'); - markdown.push_str(&markdown_body); - markdown.push('\n'); + html = preprocess_confluence_macros(&html); + write_dump_state(&config.dump_state_dir, "rewrite_macros.html", &html).await?; + + let markdown_body = convert_html_to_markdown( + &html, + ConvertOptions { + table_conversion: config.table_conversion, + remove_strikethrough_text: config.remove_strikethrough_text, + }, + ); + let markdown = append_markdown_header(&title, &page_id, page.webui.as_deref(), &markdown_body); tokio::fs::write(&output_path, markdown).await?; info!("Written: {}", output_path.display()); info!("Assets: {}", assets_info.assets_abs_dir.display()); - if let Some(dir) = &dump_state_dir { + if let Some(dir) = &config.dump_state_dir { info!("Dump state: {}", dir.display()); } @@ -279,3 +337,324 @@ async fn write_dump_state(dir: &Option, file_name: &str, contents: &str } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn make_cli( + page_url: Option<&str>, + output_path: Option<&str>, + dump_state_path: Option<&str>, + log_level: Option<&str>, + table_conversion: Option<&str>, + remove_strikethrough_text: Option, + ) -> Cli { + Cli { + page_url: page_url.map(str::to_owned), + output_path: output_path.map(PathBuf::from), + dump_state_path: dump_state_path.map(PathBuf::from), + log_level: log_level.map(str::to_owned), + table_conversion: table_conversion.map(str::to_owned), + remove_strikethrough_text, + } + } + + // ── resolve_config ─────────────────────────────────────────────────────── + + #[test] + fn resolve_config_missing_page_url_returns_error() { + let cli = make_cli(None, None, None, None, None, None); + let err = resolve_config(&cli).unwrap_err(); + assert!( + err.to_string().contains("Missing required "), + "unexpected error: {err}" + ); + } + + #[test] + fn resolve_config_invalid_table_conversion_returns_error() { + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + Some("invalid"), + None, + ); + let err = resolve_config(&cli).unwrap_err(); + assert!( + err.to_string().contains("Invalid --table-conversion"), + "unexpected error: {err}" + ); + } + + #[test] + fn resolve_config_defaults_output_dir_to_absolute_path() { + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + None, + ); + let config = resolve_config(&cli).unwrap(); + assert!( + config.output_dir.is_absolute(), + "output_dir should be absolute, got: {:?}", + config.output_dir + ); + } + + #[test] + fn resolve_config_explicit_absolute_output_path() { + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + Some("/tmp/out"), + None, + None, + None, + None, + ); + let config = resolve_config(&cli).unwrap(); + assert_eq!(config.output_dir, PathBuf::from("/tmp/out")); + } + + #[test] + fn resolve_config_table_conversion_always() { + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + Some("always"), + None, + ); + let config = resolve_config(&cli).unwrap(); + assert!(matches!(config.table_conversion, TableConversion::Always)); + } + + #[test] + fn resolve_config_remove_strikethrough_flag() { + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + Some(true), + ); + let config = resolve_config(&cli).unwrap(); + assert!(config.remove_strikethrough_text); + } + + #[test] + fn resolve_config_remove_strikethrough_env_var() { + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", "true") }; + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + None, + ); + let config = resolve_config(&cli).unwrap(); + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") }; + assert!(config.remove_strikethrough_text); + } + + #[test] + fn resolve_config_remove_strikethrough_cli_false_overrides_env_var() { + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", "true") }; + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + Some(false), + ); + let config = resolve_config(&cli).unwrap(); + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") }; + assert!(!config.remove_strikethrough_text); + } + + #[test] + fn resolve_config_remove_strikethrough_env_var_truthy_values() { + for value in &["1", "yes", "on", "TRUE"] { + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", value) }; + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + None, + ); + let config = resolve_config(&cli).unwrap(); + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") }; + assert!( + config.remove_strikethrough_text, + "expected true for env var value {value:?}" + ); + } + } + + #[test] + fn resolve_config_remove_strikethrough_env_var_falsy_values() { + for value in &["0", "no", "off", "FALSE"] { + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", value) }; + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + None, + ); + let config = resolve_config(&cli).unwrap(); + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") }; + assert!( + !config.remove_strikethrough_text, + "expected false for env var value {value:?}" + ); + } + } + + #[test] + fn resolve_config_remove_strikethrough_env_var_invalid_value_returns_error() { + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::set_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT", "invalid") }; + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + None, + None, + None, + ); + let err = resolve_config(&cli).unwrap_err(); + // SAFETY: test-only; single-threaded test environment + unsafe { std::env::remove_var("CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT") }; + assert!( + err.to_string() + .contains("Invalid CONFLUENCE2MD_REMOVE_STRIKETHROUGH_TEXT"), + "unexpected error: {err}" + ); + } + + #[test] + fn resolve_config_invalid_log_level_returns_error() { + let cli = make_cli( + Some("https://confluence.example.com/pages/viewpage.action?pageId=1"), + None, + None, + Some("TRACE"), + None, + None, + ); + let err = resolve_config(&cli).unwrap_err(); + assert!( + err.to_string().contains("parsing log level"), + "unexpected error: {err}" + ); + } + + // ── extract_base_url ───────────────────────────────────────────────────── + + #[test] + fn extract_base_url_standard_https() { + let url = "https://confluence.example.com/pages/viewpage.action?pageId=1"; + assert_eq!( + extract_base_url(url).unwrap(), + "https://confluence.example.com" + ); + } + + #[test] + fn extract_base_url_retains_explicit_port() { + let url = "https://confluence.example.com:8443/pages/viewpage.action?pageId=1"; + assert_eq!( + extract_base_url(url).unwrap(), + "https://confluence.example.com:8443" + ); + } + + #[test] + fn extract_base_url_strips_path_and_query() { + let url = "http://confluence.example.com/wiki/spaces/PROJ/pages/123/Title?foo=bar"; + assert_eq!( + extract_base_url(url).unwrap(), + "http://confluence.example.com" + ); + } + + #[test] + fn extract_base_url_invalid_url_returns_error() { + let err = extract_base_url("not-a-url").unwrap_err(); + assert!( + err.to_string().contains("Invalid page URL"), + "unexpected error: {err}" + ); + } + + // ── append_markdown_header ─────────────────────────────────────────────── + + #[test] + fn append_markdown_header_produces_h1_title() { + let md = append_markdown_header("My Page", "12345", None, "body"); + assert!(md.starts_with("# My Page\n"), "got: {md:?}"); + } + + #[test] + fn append_markdown_header_includes_page_id() { + let md = append_markdown_header("My Page", "12345", None, "body"); + assert!(md.contains("- Confluence Page ID: 12345\n")); + } + + #[test] + fn append_markdown_header_includes_webui_url_when_present() { + let md = append_markdown_header( + "My Page", + "12345", + Some("https://confluence.example.com/pages/viewpage.action?pageId=12345"), + "body", + ); + assert!(md.contains( + "- URL: https://confluence.example.com/pages/viewpage.action?pageId=12345\n" + )); + } + + #[test] + fn append_markdown_header_omits_url_line_when_webui_is_none() { + let md = append_markdown_header("My Page", "12345", None, "body"); + assert!(!md.contains("- URL:"), "unexpected URL line in: {md:?}"); + } + + #[test] + fn append_markdown_header_contains_body_after_separator() { + let md = append_markdown_header("Title", "1", None, "## Section\n\nContent here."); + let sep_pos = md.find("---\n").expect("separator not found"); + let after_sep = &md[sep_pos + 4..]; + assert!(after_sep.contains("## Section")); + assert!(after_sep.contains("Content here.")); + } + + #[test] + fn append_markdown_header_ends_with_newline() { + let md = append_markdown_header("Title", "1", None, "body"); + assert!(md.ends_with('\n')); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index b7ee2ab..89146a4 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -7,7 +7,7 @@ use std::sync::OnceLock; use serde_json::Value; use confluence2md::drawio::{extract_drawio_diagram_names, replace_drawio_img_srcs}; -use confluence2md::html::{ConvertOptions, TableConversion, convert_to_md}; +use confluence2md::html::{ConvertOptions, TableConversion, convert_html_to_markdown}; use confluence2md::plantuml::{extract_plantuml_sources, replace_plantuml_imgs_with_code}; use confluence2md::utils::{ apply_task_list_statuses, preprocess_confluence_macros, sanitize_file_name, @@ -65,7 +65,7 @@ fn sanitize_file_name_handles_fixture_title() { fn converts_export_view_html_from_fixture() { let html = export_view(); assert!(!html.is_empty()); - let md = convert_to_md(html, ConvertOptions::default()); + let md = convert_html_to_markdown(html, ConvertOptions::default()); assert!(!md.is_empty()); assert!( md.lines() @@ -77,7 +77,7 @@ fn converts_export_view_html_from_fixture() { #[test] fn output_contains_expected_section_headings() { - let md = convert_to_md(export_view(), ConvertOptions::default()); + let md = convert_html_to_markdown(export_view(), ConvertOptions::default()); assert!(md.contains("背景")); assert!(md.contains("PlantUML")); assert!(md.contains("Draw.io")); @@ -85,7 +85,7 @@ fn output_contains_expected_section_headings() { #[test] fn output_preserves_table_content() { - let md = convert_to_md(export_view(), ConvertOptions::default()); + let md = convert_html_to_markdown(export_view(), ConvertOptions::default()); assert!(md.contains("列1")); assert!(md.contains("列5")); } @@ -94,7 +94,7 @@ fn output_preserves_table_content() { fn storage_html_is_also_convertible() { let html = storage(); assert!(!html.is_empty()); - let md = convert_to_md(html, ConvertOptions::default()); + let md = convert_html_to_markdown(html, ConvertOptions::default()); assert!(!md.is_empty()); } @@ -142,21 +142,21 @@ fn extract_plantuml_sources_returns_expected_blocks() { fn rewriting_plantuml_imgs_produces_valid_fenced_blocks() { let sources = extract_plantuml_sources(Some(storage())); let rewritten = replace_plantuml_imgs_with_code(export_view(), &sources); - let md = convert_to_md(&rewritten, ConvertOptions::default()); + let md = convert_html_to_markdown(&rewritten, ConvertOptions::default()); let count = md.matches("@startuml").count(); assert_eq!(count, 2, "expected 2 @startuml occurrences, got {count}"); assert!(md.contains("```plantuml")); } -// ── End-to-end macro preprocessing + convert_to_md ──────────────────── +// ── End-to-end macro preprocessing + convert_html_to_markdown ──────────────────── #[test] -fn code_macro_roundtrip_via_preprocess_and_convert_to_md() { +fn code_macro_roundtrip_via_preprocess_and_convert_html_to_markdown() { let html = r#"rust"#; let processed = preprocess_confluence_macros(html); - let md = convert_to_md(&processed, ConvertOptions::default()); + let md = convert_html_to_markdown(&processed, ConvertOptions::default()); assert!(md.contains("```rust")); assert!(md.contains("fn main()")); assert!(md.contains("let x = 1;")); @@ -166,7 +166,7 @@ fn code_macro_roundtrip_via_preprocess_and_convert_to_md() { fn info_macro_renders_as_important_callout() { let html = r#"

Important info.

"#; let processed = preprocess_confluence_macros(html); - let md = convert_to_md(&processed, ConvertOptions::default()); + let md = convert_html_to_markdown(&processed, ConvertOptions::default()); assert!(md.contains("> [!IMPORTANT]")); assert!(md.contains("> Important info.")); } @@ -175,7 +175,7 @@ fn info_macro_renders_as_important_callout() { fn warning_macro_renders_as_warning_callout() { let html = r#"

Beware.

"#; let processed = preprocess_confluence_macros(html); - let md = convert_to_md(&processed, ConvertOptions::default()); + let md = convert_html_to_markdown(&processed, ConvertOptions::default()); assert!(md.contains("> [!CAUTION]")); } @@ -183,7 +183,7 @@ fn warning_macro_renders_as_warning_callout() { fn expand_macro_renders_as_details_summary() { let html = r#"Click

Hidden.

"#; let processed = preprocess_confluence_macros(html); - let md = convert_to_md(&processed, ConvertOptions::default()); + let md = convert_html_to_markdown(&processed, ConvertOptions::default()); assert!(md.contains("
")); assert!(md.contains("Click")); assert!(md.contains("Hidden.")); @@ -199,7 +199,7 @@ fn inline_tasks_render_as_markdown_checkboxes() { let annotated = apply_task_list_statuses(rendered, storage); let processed = preprocess_confluence_macros(&annotated); - let md = convert_to_md(&processed, ConvertOptions::default()); + let md = convert_html_to_markdown(&processed, ConvertOptions::default()); assert!(md.contains("- [x] done"), "{md}"); assert!(md.contains("- [ ] todo"), "{md}"); @@ -208,10 +208,11 @@ fn inline_tasks_render_as_markdown_checkboxes() { #[test] fn always_table_mode_unwraps_single_cell_table() { let html = "
Only cell
"; - let md = convert_to_md( + let md = convert_html_to_markdown( html, ConvertOptions { table_conversion: TableConversion::Always, + ..ConvertOptions::default() }, ); assert_eq!(md, "Only cell\n");