diff --git a/crates/archivr-core/src/capture.rs b/crates/archivr-core/src/capture.rs index 0e0a2f9..7edbb1f 100644 --- a/crates/archivr-core/src/capture.rs +++ b/crates/archivr-core/src/capture.rs @@ -707,7 +707,7 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result { + Ok((hash, file_extension, title_hint)) => { let temp_file = store_path .join("temp") .join(×tamp) @@ -743,7 +743,7 @@ pub fn perform_capture(archive_paths: &ArchivePaths, locator: &str) -> Result Result<(String, String)> { +pub fn download(url: &str, store_path: &Path, timestamp: &str) -> Result<(String, String, Option)> { let client = reqwest::blocking::Client::builder() .redirect(reqwest::redirect::Policy::limited(10)) .user_agent("archivr/0.1") @@ -45,6 +45,16 @@ pub fn download(url: &str, store_path: &Path, timestamp: &str) -> Result<(String .or_else(|| extension_from_content_type(&content_type)) .unwrap_or_default(); + // Extract title before consuming the response body. + let title_hint = title_from_content_disposition( + response + .headers() + .get(reqwest::header::CONTENT_DISPOSITION) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + ) + .or_else(|| title_from_url(response.url().path())); + let temp_dir = store_path.join("temp").join(timestamp); std::fs::create_dir_all(&temp_dir).context("failed to create temp dir")?; @@ -57,7 +67,7 @@ pub fn download(url: &str, store_path: &Path, timestamp: &str) -> Result<(String .with_context(|| format!("failed to write downloaded file to {}", out_file.display()))?; let hash = hash_file(&out_file)?; - Ok((hash, extension)) + Ok((hash, extension, title_hint)) } fn content_type_is_html(content_type: &str) -> bool { @@ -107,6 +117,87 @@ fn extension_from_content_type(content_type: &str) -> Option { } } +/// Extracts a filename from a `Content-Disposition` header value. +/// +/// Prefers `filename*=` (RFC 5987 percent-encoded, e.g. `filename*=UTF-8''Report%20Final.pdf`) +/// over plain `filename=`. Returns `None` if neither is present or the value is empty. +fn title_from_content_disposition(cd: &str) -> Option { + // RFC 5987: filename*=charset'language'encoded + for part in cd.split(';') { + let part = part.trim(); + if let Some(val) = part.strip_prefix("filename*=") { + let val = val.trim().trim_matches('"'); + // encoded portion is after the second apostrophe + if let Some(encoded) = val.splitn(3, '\'').nth(2) { + let name = percent_decode(encoded); + if !name.is_empty() { + return Some(name); + } + } + } + } + // Plain filename= + for part in cd.split(';') { + let part = part.trim(); + if let Some(val) = part.strip_prefix("filename=") { + let val = val.trim().trim_matches('"'); + if !val.is_empty() { + return Some(val.to_string()); + } + } + } + None +} + +/// Derives a title from the last non-empty path segment of a URL path string. +/// +/// Input is the raw percent-encoded path from the final URL after redirects +/// (e.g. `/papers/Facharbeit.pdf`). Returns `None` if the path has no meaningful segment. +fn title_from_url(path: &str) -> Option { + let segment = path + .split('?') + .next() + .unwrap_or(path) + .rsplit('/') + .find(|s| !s.is_empty())?; + let decoded = percent_decode(segment); + if decoded.is_empty() { None } else { Some(decoded) } +} + +/// Percent-decodes a string. Handles ASCII percent-encoded sequences; multi-byte +/// UTF-8 sequences are decoded correctly when each byte is encoded as `%XX`. +fn percent_decode(s: &str) -> String { + let mut bytes: Vec = Vec::with_capacity(s.len()); + let src = s.as_bytes(); + let mut i = 0; + while i < src.len() { + if src[i] == b'%' && i + 2 < src.len() { + let hi = src[i + 1]; + let lo = src[i + 2]; + let nibble = |b: u8| -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } + }; + if let (Some(h), Some(l)) = (nibble(hi), nibble(lo)) { + bytes.push((h << 4) | l); + i += 3; + continue; + } + } + if src[i] == b'+' { + bytes.push(b' '); + } else { + bytes.push(src[i]); + } + i += 1; + } + String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) +} + #[cfg(test)] mod tests { use super::*; @@ -160,4 +251,72 @@ mod tests { fn content_type_is_html_pdf_is_not_html() { assert!(!content_type_is_html("application/pdf")); } + + #[test] + fn title_from_url_plain_filename() { + assert_eq!( + title_from_url("/papers/Facharbeit.pdf"), + Some("Facharbeit.pdf".to_string()) + ); + } + + #[test] + fn title_from_url_encoded() { + assert_eq!( + title_from_url("/files/My%20Report%202026.pdf"), + Some("My Report 2026.pdf".to_string()) + ); + } + + #[test] + fn title_from_url_root_is_none() { + assert_eq!(title_from_url("/"), None); + } + + #[test] + fn title_from_url_no_slash() { + assert_eq!(title_from_url("data.csv"), Some("data.csv".to_string())); + } + + #[test] + fn title_from_content_disposition_plain() { + assert_eq!( + title_from_content_disposition("attachment; filename=\"Facharbeit.pdf\""), + Some("Facharbeit.pdf".to_string()) + ); + } + + #[test] + fn title_from_content_disposition_rfc5987() { + assert_eq!( + title_from_content_disposition("attachment; filename*=UTF-8''Facharbeit%20Final.pdf"), + Some("Facharbeit Final.pdf".to_string()) + ); + } + + #[test] + fn title_from_content_disposition_empty() { + assert_eq!(title_from_content_disposition(""), None); + } + + #[test] + fn title_from_content_disposition_prefers_rfc5987() { + assert_eq!( + title_from_content_disposition( + "attachment; filename=\"old.pdf\"; filename*=UTF-8''new.pdf" + ), + Some("new.pdf".to_string()) + ); + } + + #[test] + fn percent_decode_utf8() { + // ä = 0xC3 0xA4 + assert_eq!(percent_decode("%C3%A4"), "ä"); + } + + #[test] + fn percent_decode_plus_is_space() { + assert_eq!(percent_decode("hello+world"), "hello world"); + } }