Class: ClaudeConversationExporter

Inherits:
Object
  • Object
show all
Defined in:
lib/claude_conversation_exporter.rb

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(project_path = Dir.pwd, output_dir = 'claude-conversations', options = {}) ⇒ ClaudeConversationExporter

Returns a new instance of ClaudeConversationExporter.



163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/claude_conversation_exporter.rb', line 163

def initialize(project_path = Dir.pwd, output_dir = 'claude-conversations', options = {})
  @project_path = File.expand_path(project_path)
  @output_dir = File.expand_path(output_dir)
  @claude_home = find_claude_home
  @compacted_conversation_processed = false
  @options = options
  @show_timestamps = options[:timestamps] || false
  @silent = options[:silent] || false
  @leaf_summaries = []
  @skipped_messages = []
  @secrets_detected = []
  setup_date_filters
  setup_secret_detection
end

Class Method Details

.export(project_path = Dir.pwd, output_dir = 'claude-conversations', options = {}) ⇒ Object



13
14
15
# File 'lib/claude_conversation_exporter.rb', line 13

def export(project_path = Dir.pwd, output_dir = 'claude-conversations', options = {})
  new(project_path, output_dir, options).export
end

.extract_title_from_summaries_or_markdown(markdown_file, leaf_summaries = []) ⇒ Object



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/claude_conversation_exporter.rb', line 136

def extract_title_from_summaries_or_markdown(markdown_file, leaf_summaries = [])
  # Try to get title from leaf summaries first
  if leaf_summaries.any?
    # Use the first (oldest) summary as the main title
    return leaf_summaries.first[:summary]
  end

  # Fallback: read the markdown file and extract title from content
  begin
    content = File.read(markdown_file)

    # Look for the first user message content as fallback
    if content.match(/## 👤 User\s*\n\n(.+?)(?:\n\n|$)/m)
      first_user_message = $1.strip
      # Clean up the message for use as title
      title_words = first_user_message.split(/\s+/).first(8).join(' ')
      return title_words.length > 60 ? title_words[0..57] + '...' : title_words
    end

    # Final fallback
    "Claude Code Conversation"
  rescue
    "Claude Code Conversation"
  end
end

.generate_preview(output_path_or_dir, open_browser = true, leaf_summaries = [], template_name = 'default', silent = false) ⇒ Object



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/claude_conversation_exporter.rb', line 17

def generate_preview(output_path_or_dir, open_browser = true, leaf_summaries = [], template_name = 'default', silent = false)
  # Helper method for output
  output_helper = lambda { |message| puts message unless silent }

  # Handle both directory and specific file paths
  if output_path_or_dir.end_with?('.md') && File.exist?(output_path_or_dir)
    # Specific markdown file provided
    latest_md = output_path_or_dir
    output_dir = File.dirname(output_path_or_dir)
  else
    # Directory provided - find the latest markdown file
    output_dir = output_path_or_dir
    latest_md = Dir.glob(File.join(output_dir, '*.md')).sort.last
  end

  if latest_md.nil? || !File.exist?(latest_md)
    output_helper.call "No markdown files found in #{output_dir}/"
    return false
  end

  output_helper.call "Creating preview for: #{File.basename(latest_md)}"

  # Check if cmark-gfm is available
  unless system('which cmark-gfm > /dev/null 2>&1')
    output_helper.call "Error: cmark-gfm not found. Install it with:"
    output_helper.call "  brew install cmark-gfm"
    return false
  end

  # Use cmark-gfm to convert markdown to HTML with --unsafe for collapsed sections
  # The --unsafe flag prevents escaping in code blocks
  stdout, stderr, status = Open3.capture3(
    'cmark-gfm', '--unsafe', '--extension', 'table', '--extension', 'strikethrough',
    '--extension', 'autolink', '--extension', 'tagfilter', '--extension', 'tasklist',
    latest_md
  )

  unless status.success?
    output_helper.call "Error running cmark-gfm: #{stderr}"
    return false
  end

  md_html = stdout

  # Load ERB template - support both template names and file paths
  if template_name.include?('/') || template_name.end_with?('.erb')
    # Full path provided
    template_path = template_name
  else
    # Template name provided - look in templates directory
    template_path = File.join(File.dirname(__FILE__), 'templates', "#{template_name}.html.erb")
  end
  unless File.exist?(template_path)
    output_helper.call "Error: ERB template not found at #{template_path}"
    return false
  end

  template = File.read(template_path)
  erb = ERB.new(template)

  # Create the complete HTML content using ERB template
  content = md_html
  title = extract_title_from_summaries_or_markdown(latest_md, leaf_summaries)
  full_html = erb.result(binding)

  # Create HTML file in output directory
  html_filename = latest_md.gsub(/\.md$/, '.html')
  File.write(html_filename, full_html)

  output_helper.call "HTML preview: #{html_filename}"

  # Open in the default browser only if requested
  if open_browser
    system("open", html_filename)
    output_helper.call "Opening in browser..."
  end

  html_filename
end

.include_prismObject



97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/claude_conversation_exporter.rb', line 97

def include_prism
  # Prism.js CSS and JavaScript for syntax highlighting
  # MIT License - https://github.com/PrismJS/prism
  css = File.read(File.join(__dir__, 'assets/prism.css'))
  js = File.read(File.join(__dir__, 'assets/prism.js'))
  
  # Additional language components
  language_components = %w[
    prism-python.js
    prism-markdown.js
    prism-typescript.js
    prism-json.js
    prism-yaml.js
    prism-bash.js
  ]
  
  # Load and concatenate language components
  language_js = language_components.map do |component|
    component_path = File.join(__dir__, 'assets', component)
    File.exist?(component_path) ? File.read(component_path) : ""
  end.join("\n")

  # Add initialization code to automatically highlight code blocks
  init_js = <<~JS

    /* Initialize Prism.js */
    if (typeof window !== 'undefined' && window.document) {
        document.addEventListener('DOMContentLoaded', function() {
            if (typeof Prism !== 'undefined' && Prism.highlightAll) {
                Prism.highlightAll();
            }
        });
    }
  JS

  # Return both CSS and JS as a complete block
  "<style>#{css}</style>\n<script>#{js}\n#{language_js}#{init_js}</script>"
end

Instance Method Details

#exportObject



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/claude_conversation_exporter.rb', line 178

def export
  if @options[:jsonl]
    # Process specific JSONL file
    session_files = [File.expand_path(@options[:jsonl])]
    session_dir = File.dirname(session_files.first)
  else
    # Scan for session files in directory
    session_dir = find_session_directory
    session_files = Dir.glob(File.join(session_dir, '*.jsonl')).sort
    raise "No session files found in #{session_dir}" if session_files.empty?
  end

  # Handle output path - could be a directory or specific file
  if @output_dir.end_with?('.md')
    # Specific file path provided
    output_path = File.expand_path(@output_dir)
    output_dir = File.dirname(output_path)
    FileUtils.mkdir_p(output_dir)
  else
    # Directory provided
    FileUtils.mkdir_p(@output_dir)
    output_dir = @output_dir
    output_path = nil  # Will be generated later
  end

  if @options[:jsonl]
    output "Processing specific JSONL file: #{File.basename(session_files.first)}"
  else
    output "Found #{session_files.length} session file(s) in #{session_dir}"
  end

  sessions = []
  total_messages = 0

  session_files.each do |session_file|
    session = process_session(session_file)
    next if session[:messages].empty?

    sessions << session
    output "#{session[:session_id]}: #{session[:messages].length} messages"
    total_messages += session[:messages].length
  end

  # Sort sessions by first timestamp to ensure chronological order
  sessions.sort_by! { |session| session[:first_timestamp] || '1970-01-01T00:00:00Z' }

  if sessions.empty?
    output "\nNo sessions to export"
    return { sessions_exported: 0, total_messages: 0 }
  end

  # Generate output path if not already specified
  if output_path.nil?
    filename = generate_combined_filename(sessions)
    output_path = File.join(output_dir, filename)
  end

  File.write(output_path, format_combined_markdown(sessions))

  # Write skip log if there were any skipped messages
  write_skip_log(output_path)

  # Write secrets detection log if any secrets were detected
  write_secrets_log(output_path)

  output "\nExported #{sessions.length} conversations (#{total_messages} total messages) to #{output_path}"

  { sessions_exported: sessions.length, total_messages: total_messages, leaf_summaries: @leaf_summaries, output_file: output_path }
end