From 9226ad2f07aaecce7c7f3af63a9c9af15644b6bd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 19:32:03 +0000 Subject: [PATCH] Add date-prefixed filenames, BufWriter optimization, URL decoding, v0.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prefix output filenames with YYYY-MM-DD from session dateCreated for chronological sorting (e.g. 2025-06-15_My_Session.html) - Replace fs::write with BufWriter for significantly faster file writes, especially when overwriting existing output directories - Optimize unchanged-file detection by checking file size via metadata before reading file contents for comparison - URL-decode percent-encoded workspace paths (e.g. %3A → :) so Windows paths display correctly in HTML output - Add Rust build caching (Swatinem/rust-cache) to CI for faster builds - Fix clippy lint (push_str with single char → push) - Bump version to 0.6.0 across Cargo.toml, pyproject.toml, __init__.py, and README.md find-links URLs https://claude.ai/code/session_016SkNLJ5X48v5Fw9MTGhSrz --- .github/workflows/ci.yml | 7 + Cargo.toml | 2 +- README.md | 13 +- pyproject.toml | 2 +- python/continue_transcripts/__init__.py | 2 +- src/main.rs | 236 ++++++++++++++++++++---- 6 files changed, 220 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfb2401..a09d408 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 - run: cargo test build: @@ -47,6 +48,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} - name: Install maturin run: pip install maturin - name: Build wheel diff --git a/Cargo.toml b/Cargo.toml index 29170f8..b3f4f34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "continue-transcripts" -version = "0.5.0" +version = "0.6.0" edition = "2021" description = "Convert continue.dev session files to readable HTML transcripts" license = "MIT" diff --git a/README.md b/README.md index d082d34..30d38f5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Convert [continue.dev](https://continue.dev) session files to readable, self-con - Optional title-based filtering with `--filter` - **Parallel processing** with configurable worker count (`--workers`) - **Terminal summary** — file counts, elapsed time, and per-file status indicators +- **Date-prefixed filenames** — output files are named `YYYY-MM-DD_Title.html` for easy chronological sorting - **Filename deduplication** — output filenames are truncated to 60 characters with automatic `_1`, `_2` suffixes on collision - Responsive design — works on desktop and mobile @@ -44,8 +45,8 @@ Output looks like: ``` Using 4 worker thread(s) Discovered 12 JSON file(s) - ✅ transcripts/Fix_failing_tests_in_auth_module.html - ✅ transcripts/Refactor_auth_module.html + ✅ transcripts/2025-06-15_Fix_failing_tests_in_auth_module.html + ✅ transcripts/2025-06-14_Refactor_auth_module.html ... ✅ transcripts/index.html @@ -72,7 +73,7 @@ Install globally so the `continue-transcripts` command is always available: ```sh uv tool install continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.5.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 ``` The `continue-transcripts` command is then available on your `PATH`. @@ -82,7 +83,7 @@ To upgrade later (update the version in the URL): ```sh uv tool install --upgrade continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.5.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 ``` To uninstall: @@ -99,7 +100,7 @@ Run without installing: uvx \ --no-index \ --from continue-transcripts \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.5.0 \ + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 \ continue-transcripts ./sessions ``` @@ -114,7 +115,7 @@ uv venv .venv source .venv/bin/activate uv pip install continue-transcripts \ --no-index \ - --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.5.0 + --find-links https://github.com/curtisalexander/continue-transcripts/releases/expanded_assets/v0.6.0 ``` ### Building from source diff --git a/pyproject.toml b/pyproject.toml index 2db7437..4c1b9d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "continue-transcripts" -version = "0.5.0" +version = "0.6.0" description = "Convert continue.dev session files to readable HTML transcripts" readme = "README.md" license = { text = "MIT" } diff --git a/python/continue_transcripts/__init__.py b/python/continue_transcripts/__init__.py index 13b0a67..294f397 100644 --- a/python/continue_transcripts/__init__.py +++ b/python/continue_transcripts/__init__.py @@ -1,3 +1,3 @@ """continue-transcripts - Convert continue.dev session files to readable HTML transcripts.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/main.rs b/src/main.rs index 1d372f7..ffa5d0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use pulldown_cmark::{CodeBlockKind, Event, Options, Parser as MdParser, Tag, Tag use rayon::prelude::*; use serde::Deserialize; use std::fs; +use std::io::{BufWriter, Write}; use std::path::{Path, PathBuf}; use std::time::Instant; use syntect::highlighting::ThemeSet; @@ -1006,7 +1007,7 @@ fn render_tool_result_inline( if let Some(hl) = highlighted { html.push_str(" "); html.push_str(&hl); - html.push_str("\n"); + html.push('\n'); } else { html.push_str("
");
                 html.push_str(&encode_text(&content_text));
@@ -1648,10 +1649,11 @@ fn render_session(session: &Session, source_path: Option<&Path>) -> String {
         .or_else(|| source_path.and_then(file_modified_date))
         .unwrap_or_else(|| "Unknown date".to_string());
 
-    let workspace = if session.workspace_directory.is_empty() {
-        "N/A"
+    let workspace_decoded = percent_decode(&session.workspace_directory);
+    let workspace = if workspace_decoded.is_empty() {
+        "N/A".to_string()
     } else {
-        &session.workspace_directory
+        workspace_decoded
     };
 
     let model = extract_model(session);
@@ -1714,7 +1716,7 @@ fn render_session(session: &Session, source_path: Option<&Path>) -> String {
         date = encode_text(&date_str),
         model_meta = model_meta,
         tokens_meta = tokens_meta,
-        workspace = encode_text(workspace),
+        workspace = encode_text(&workspace),
         user_count = user_count,
         assistant_count = assistant_count,
         system_prompt = system_prompt_html,
@@ -2528,9 +2530,37 @@ fn sanitize_filename(s: &str) -> String {
     truncated.trim_end_matches('_').to_string()
 }
 
-/// Generate a unique filename from a title, appending a numeric suffix if needed.
+/// Extract a YYYY-MM-DD date prefix from a date string.
+/// Handles ISO 8601 (e.g. "2025-06-15T10:30:00Z") and plain date strings.
+fn extract_date_prefix(date: &str) -> Option {
+    // Try to extract YYYY-MM-DD from the beginning of the string
+    if date.len() >= 10 {
+        let prefix = &date[..10];
+        // Validate it looks like a date: YYYY-MM-DD
+        let parts: Vec<&str> = prefix.split('-').collect();
+        if parts.len() == 3
+            && parts[0].len() == 4
+            && parts[1].len() == 2
+            && parts[2].len() == 2
+            && parts[0].chars().all(|c| c.is_ascii_digit())
+            && parts[1].chars().all(|c| c.is_ascii_digit())
+            && parts[2].chars().all(|c| c.is_ascii_digit())
+        {
+            return Some(prefix.to_string());
+        }
+    }
+    None
+}
+
+/// Generate a unique filename from a title and optional date, appending a
+/// numeric suffix if needed. When a date is available, the filename is
+/// prefixed with `YYYY-MM-DD_` for easy chronological sorting.
 /// The `used` set tracks filenames already claimed in this run.
-fn unique_filename(title: &str, used: &mut std::collections::HashSet) -> String {
+fn unique_filename(
+    title: &str,
+    date: Option<&str>,
+    used: &mut std::collections::HashSet,
+) -> String {
     let base = sanitize_filename(title);
     let base = if base.is_empty() {
         "untitled".to_string()
@@ -2538,14 +2568,20 @@ fn unique_filename(title: &str, used: &mut std::collections::HashSet) ->
         base
     };
 
-    let candidate = format!("{}.html", base);
+    // Prepend date prefix if available
+    let base = match date.and_then(extract_date_prefix) {
+        Some(prefix) => format!("{prefix}_{base}"),
+        None => base,
+    };
+
+    let candidate = format!("{base}.html");
     if used.insert(candidate.clone()) {
         return candidate;
     }
 
     // Collision — append numeric suffix
     for n in 1u32.. {
-        let candidate = format!("{}_{}.html", base, n);
+        let candidate = format!("{base}_{n}.html");
         if used.insert(candidate.clone()) {
             return candidate;
         }
@@ -2554,6 +2590,29 @@ fn unique_filename(title: &str, used: &mut std::collections::HashSet) ->
     unreachable!()
 }
 
+/// Decode percent-encoded strings (e.g. `%3A` → `:`).
+/// Handles UTF-8 sequences that span multiple percent-encoded bytes.
+fn percent_decode(input: &str) -> String {
+    let bytes = input.as_bytes();
+    let mut decoded = Vec::with_capacity(bytes.len());
+    let mut i = 0;
+    while i < bytes.len() {
+        if bytes[i] == b'%' && i + 2 < bytes.len() {
+            if let Ok(byte) = u8::from_str_radix(
+                std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""),
+                16,
+            ) {
+                decoded.push(byte);
+                i += 3;
+                continue;
+            }
+        }
+        decoded.push(bytes[i]);
+        i += 1;
+    }
+    String::from_utf8(decoded).unwrap_or_else(|_| input.to_string())
+}
+
 /// Result of processing a single session file.
 enum ProcessResult {
     /// Successfully processed: (html, title, date, source_path)
@@ -2665,25 +2724,50 @@ fn main() {
     for result in &results {
         match result {
             ProcessResult::Success(html, title, date, source) => {
-                let filename = unique_filename(title, &mut used_filenames);
+                let filename = unique_filename(
+                    title,
+                    if date.is_empty() { None } else { Some(date.as_str()) },
+                    &mut used_filenames,
+                );
                 let out_path = output_dir.join(&filename);
 
-                // Skip writing if the file already exists with identical content
-                let needs_write = match fs::read(&out_path) {
-                    Ok(existing) => existing != html.as_bytes(),
-                    Err(_) => true,
+                // Skip writing if the file already exists with identical content.
+                // Check file size first to avoid reading large files unnecessarily.
+                let needs_write = match fs::metadata(&out_path) {
+                    Ok(meta) if meta.len() == html.len() as u64 => {
+                        match fs::read(&out_path) {
+                            Ok(existing) => existing != html.as_bytes(),
+                            Err(_) => true,
+                        }
+                    }
+                    Ok(_) => true,  // size differs — must rewrite
+                    Err(_) => true, // file doesn't exist
                 };
 
                 if needs_write {
-                    match fs::write(&out_path, html) {
-                        Ok(_) => {
-                            eprintln!("  \u{2705} {}", out_path.display());
-                            index_entries.push((title.clone(), filename, date.clone()));
-                            files_written += 1;
+                    match fs::File::create(&out_path) {
+                        Ok(file) => {
+                            let mut writer = BufWriter::new(file);
+                            match writer.write_all(html.as_bytes()).and_then(|_| writer.flush()) {
+                                Ok(_) => {
+                                    eprintln!("  \u{2705} {}", out_path.display());
+                                    index_entries.push((title.clone(), filename, date.clone()));
+                                    files_written += 1;
+                                }
+                                Err(e) => {
+                                    eprintln!(
+                                        "  \u{274c} Failed to write {}: {}",
+                                        out_path.display(),
+                                        e
+                                    );
+                                    errors.push((source.clone(), format!("write failed: {}", e)));
+                                    files_errored += 1;
+                                }
+                            }
                         }
                         Err(e) => {
                             eprintln!(
-                                "  \u{274c} Failed to write {}: {}",
+                                "  \u{274c} Failed to create {}: {}",
                                 out_path.display(),
                                 e
                             );
@@ -2713,18 +2797,36 @@ fn main() {
     if !index_entries.is_empty() {
         let index_html = render_index(&index_entries);
         let index_path = output_dir.join("index.html");
-        let index_needs_write = match fs::read(&index_path) {
-            Ok(existing) => existing != index_html.as_bytes(),
+        let index_needs_write = match fs::metadata(&index_path) {
+            Ok(meta) if meta.len() == index_html.len() as u64 => {
+                match fs::read(&index_path) {
+                    Ok(existing) => existing != index_html.as_bytes(),
+                    Err(_) => true,
+                }
+            }
+            Ok(_) => true,
             Err(_) => true,
         };
         if index_needs_write {
-            match fs::write(&index_path, &index_html) {
-                Ok(_) => {
-                    eprintln!("  \u{2705} {}", index_path.display());
-                    index_written = true;
+            match fs::File::create(&index_path) {
+                Ok(file) => {
+                    let mut writer = BufWriter::new(file);
+                    match writer
+                        .write_all(index_html.as_bytes())
+                        .and_then(|_| writer.flush())
+                    {
+                        Ok(_) => {
+                            eprintln!("  \u{2705} {}", index_path.display());
+                            index_written = true;
+                        }
+                        Err(e) => {
+                            eprintln!("  \u{274c} Failed to write index: {}", e);
+                            files_errored += 1;
+                        }
+                    }
                 }
                 Err(e) => {
-                    eprintln!("  \u{274c} Failed to write index: {}", e);
+                    eprintln!("  \u{274c} Failed to create index: {}", e);
                     files_errored += 1;
                 }
             }
@@ -2867,8 +2969,8 @@ mod tests {
     #[test]
     fn test_unique_filename_no_collision() {
         let mut used = std::collections::HashSet::new();
-        let f1 = unique_filename("Session One", &mut used);
-        let f2 = unique_filename("Session Two", &mut used);
+        let f1 = unique_filename("Session One", None, &mut used);
+        let f2 = unique_filename("Session Two", None, &mut used);
         assert_eq!(f1, "Session_One.html");
         assert_eq!(f2, "Session_Two.html");
         assert_ne!(f1, f2);
@@ -2877,9 +2979,9 @@ mod tests {
     #[test]
     fn test_unique_filename_collision() {
         let mut used = std::collections::HashSet::new();
-        let f1 = unique_filename("Same Title", &mut used);
-        let f2 = unique_filename("Same Title", &mut used);
-        let f3 = unique_filename("Same Title", &mut used);
+        let f1 = unique_filename("Same Title", None, &mut used);
+        let f2 = unique_filename("Same Title", None, &mut used);
+        let f3 = unique_filename("Same Title", None, &mut used);
         assert_eq!(f1, "Same_Title.html");
         assert_eq!(f2, "Same_Title_1.html");
         assert_eq!(f3, "Same_Title_2.html");
@@ -2888,10 +2990,78 @@ mod tests {
     #[test]
     fn test_unique_filename_empty_title() {
         let mut used = std::collections::HashSet::new();
-        let f = unique_filename("", &mut used);
+        let f = unique_filename("", None, &mut used);
         assert_eq!(f, "untitled.html");
     }
 
+    #[test]
+    fn test_unique_filename_with_date_prefix() {
+        let mut used = std::collections::HashSet::new();
+        let f = unique_filename("My Session", Some("2025-06-15T10:30:00Z"), &mut used);
+        assert_eq!(f, "2025-06-15_My_Session.html");
+    }
+
+    #[test]
+    fn test_unique_filename_date_prefix_collision() {
+        let mut used = std::collections::HashSet::new();
+        let f1 = unique_filename("Chat", Some("2025-06-15T10:00:00Z"), &mut used);
+        let f2 = unique_filename("Chat", Some("2025-06-15T14:00:00Z"), &mut used);
+        assert_eq!(f1, "2025-06-15_Chat.html");
+        assert_eq!(f2, "2025-06-15_Chat_1.html");
+    }
+
+    #[test]
+    fn test_unique_filename_invalid_date_no_prefix() {
+        let mut used = std::collections::HashSet::new();
+        let f = unique_filename("Session", Some("not-a-date"), &mut used);
+        assert_eq!(f, "Session.html");
+    }
+
+    #[test]
+    fn test_extract_date_prefix() {
+        assert_eq!(
+            extract_date_prefix("2025-06-15T10:30:00Z"),
+            Some("2025-06-15".to_string())
+        );
+        assert_eq!(
+            extract_date_prefix("2025-01-01"),
+            Some("2025-01-01".to_string())
+        );
+        assert_eq!(extract_date_prefix("not-a-date"), None);
+        assert_eq!(extract_date_prefix("short"), None);
+    }
+
+    // -----------------------------------------------------------------------
+    // Percent decode tests
+    // -----------------------------------------------------------------------
+
+    #[test]
+    fn test_percent_decode_basic() {
+        assert_eq!(percent_decode("hello%20world"), "hello world");
+        assert_eq!(percent_decode("%2Fhome%2Fuser"), "/home/user");
+    }
+
+    #[test]
+    fn test_percent_decode_windows_path() {
+        assert_eq!(
+            percent_decode("C%3A%5CUsers%5Ctest"),
+            "C:\\Users\\test"
+        );
+    }
+
+    #[test]
+    fn test_percent_decode_no_encoding() {
+        assert_eq!(percent_decode("/home/user/project"), "/home/user/project");
+    }
+
+    #[test]
+    fn test_percent_decode_mixed() {
+        assert_eq!(
+            percent_decode("/path/to%20my%20file.txt"),
+            "/path/to my file.txt"
+        );
+    }
+
     #[test]
     fn test_markdown_to_html() {
         let html = markdown_to_html("**bold** text");