toilet_tracker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Add lib directory to load path for development (when running from source)
5
+ lib_path = File.expand_path('../lib', __dir__)
6
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
7
+
8
+ # Only use bundler/setup when running from source directory (has Gemfile)
9
+ gemfile_path = File.expand_path('../Gemfile', __dir__)
10
+ if File.exist?(gemfile_path)
11
+ begin
12
+ require 'bundler/setup'
13
+ rescue LoadError
14
+ # If bundler is not available, just continue
15
+ end
16
+ end
17
+
18
+ require 'active_support'
19
+ require 'active_support/core_ext'
20
+ require 'toilet_tracker'
21
+ require 'thor'
22
+ require 'tty-prompt'
23
+ require 'json'
24
+ require 'csv'
25
+
26
+ module ToiletTracker
27
+ class CLI < Thor
28
+ class_option :timezone, aliases: '-t', type: :string, default: 'UTC',
29
+ desc: 'Default timezone (default: UTC)'
30
+ class_option :errors, aliases: '-e', type: :boolean, default: false,
31
+ desc: 'Include error results in output'
32
+ class_option :retroactive, aliases: '-r', type: :boolean, default: true,
33
+ desc: 'Apply retroactive timezone shifts'
34
+
35
+ desc 'parse FILE', 'Parse WhatsApp messages from a file'
36
+ method_option :format, aliases: '-f', type: :string, default: 'table',
37
+ enum: %w[table json csv], desc: 'Output format'
38
+ def parse(file_path = nil)
39
+ setup_configuration
40
+
41
+ unless file_path
42
+ error 'FILE argument is required'
43
+ puts 'Usage: toilet_tracker parse FILE'
44
+ exit 1
45
+ end
46
+
47
+ unless File.exist?(file_path)
48
+ error "File not found: #{file_path}"
49
+ exit 1
50
+ end
51
+
52
+ lines = read_input_file(file_path)
53
+ results = ToiletTracker.parse_lines(lines)
54
+
55
+ output_results(results)
56
+ end
57
+
58
+ desc 'interactive', 'Interactive mode for parsing messages'
59
+ def interactive
60
+ setup_configuration
61
+ prompt = TTY::Prompt.new
62
+
63
+ loop do
64
+ action = prompt.select('What would you like to do?') do |menu|
65
+ menu.choice 'Parse a file', :parse_file
66
+ menu.choice 'Parse from clipboard', :parse_clipboard
67
+ menu.choice 'Enter message manually', :manual_entry
68
+ menu.choice 'Exit', :exit
69
+ end
70
+
71
+ case action
72
+ when :parse_file
73
+ file_path = prompt.ask('Enter file path:')
74
+ if File.exist?(file_path)
75
+ begin
76
+ lines = read_input_file(file_path)
77
+ results = ToiletTracker.parse_lines(lines)
78
+
79
+ handle_interactive_results(results, prompt)
80
+ rescue ToiletTracker::Utils::ZipHandler::ZipError => e
81
+ prompt.error("Zip file error: #{e.message}")
82
+ rescue StandardError => e
83
+ prompt.error("File reading error: #{e.message}")
84
+ end
85
+ else
86
+ prompt.error("File not found: #{file_path}")
87
+ end
88
+
89
+ when :parse_clipboard
90
+ prompt.say('Please paste your WhatsApp messages (press Ctrl+D when done):')
91
+ lines = $stdin.readlines(chomp: true)
92
+ results = ToiletTracker.parse_lines(lines)
93
+
94
+ handle_interactive_results(results, prompt)
95
+
96
+ when :manual_entry
97
+ message = prompt.ask('Enter WhatsApp message:')
98
+ results = ToiletTracker.parse_lines([message])
99
+ handle_interactive_results(results, prompt)
100
+
101
+ when :exit
102
+ prompt.say('Goodbye! 💩')
103
+ break
104
+ end
105
+
106
+ prompt.keypress('Press any key to continue...', keys: [:return])
107
+ end
108
+ end
109
+
110
+ desc 'version', 'Show version'
111
+ def version
112
+ puts "ToiletTracker #{ToiletTracker::VERSION}"
113
+ end
114
+
115
+ desc 'stats FILE', 'Show parsing statistics for a file'
116
+ def stats(file_path = nil)
117
+ setup_configuration
118
+
119
+ unless file_path
120
+ error 'FILE argument is required'
121
+ puts 'Usage: toilet_tracker stats FILE'
122
+ exit 1
123
+ end
124
+
125
+ unless File.exist?(file_path)
126
+ error "File not found: #{file_path}"
127
+ exit 1
128
+ end
129
+
130
+ lines = read_input_file(file_path)
131
+ results = ToiletTracker.parse_lines(lines)
132
+
133
+ show_statistics(results, lines.size)
134
+ end
135
+
136
+ private
137
+
138
+ def setup_configuration
139
+ ToiletTracker.configure do |config|
140
+ config.default_timezone = options[:timezone]
141
+ end
142
+ end
143
+
144
+ def read_input_file(file_path)
145
+ if ToiletTracker::Utils::ZipHandler.zip_file?(file_path)
146
+ temp_chat_file = ToiletTracker::Utils::ZipHandler.extract_chat_file(file_path)
147
+
148
+ # Ensure temp file is cleaned up after reading
149
+ begin
150
+ File.readlines(temp_chat_file, chomp: true)
151
+ ensure
152
+ FileUtils.rm_f(temp_chat_file)
153
+ end
154
+ else
155
+ File.readlines(file_path, chomp: true)
156
+ end
157
+ end
158
+
159
+ def handle_interactive_results(results, prompt)
160
+ # First, show the results
161
+ format = prompt.select('Output format?', %w[table json csv])
162
+ output_results(results, format: format)
163
+
164
+ # Ask if they want to save to file
165
+ return unless prompt.yes?('Save results to file?')
166
+
167
+ default_filename = "toilet_tracker_results_#{Time.now.strftime('%Y%m%d_%H%M%S')}.#{format == 'table' ? 'txt' : format}"
168
+ filename = prompt.ask('Enter filename:', default: default_filename)
169
+
170
+ save_results_to_file(results, filename, format, prompt)
171
+ end
172
+
173
+ def save_results_to_file(results, filename, format, prompt)
174
+ case format
175
+ when 'json'
176
+ File.write(filename, JSON.pretty_generate(serialize_results(filter_results(results))))
177
+ when 'csv'
178
+ require 'csv'
179
+ CSV.open(filename, 'w') do |csv|
180
+ csv << ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
181
+ filter_results(results).each do |result|
182
+ next unless result
183
+
184
+ row = [
185
+ result.type,
186
+ result.status,
187
+ result.message&.sender,
188
+ result.message&.timestamp&.iso8601,
189
+ extract_event_time(result)&.iso8601,
190
+ extract_shift(result),
191
+ result.message&.content
192
+ ]
193
+ csv << row
194
+ end
195
+ end
196
+ when 'table'
197
+ File.open(filename, 'w') do |file|
198
+ filtered_results = filter_results(results)
199
+ if filtered_results.empty?
200
+ file.puts 'No results found.'
201
+ else
202
+ headers = ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
203
+ rows = filtered_results.filter_map do |result|
204
+ next unless result
205
+
206
+ [
207
+ result.type || 'unknown',
208
+ result.status || 'unknown',
209
+ result.message&.sender || 'unknown',
210
+ result.message&.timestamp&.strftime('%Y-%m-%d %H:%M:%S') || 'unknown',
211
+ extract_event_time(result)&.strftime('%Y-%m-%d %H:%M:%S') || 'unknown',
212
+ extract_shift(result) || '0',
213
+ (result.message&.content || '')[0..50]
214
+ ]
215
+ end
216
+
217
+ # Calculate column widths
218
+ widths = headers.map.with_index do |header, i|
219
+ [header.length, *rows.map { |row| row[i].to_s.length }].max
220
+ end
221
+
222
+ # Write header
223
+ header_line = headers.map.with_index { |h, i| h.ljust(widths[i]) }.join(' ')
224
+ file.puts header_line
225
+ file.puts '-' * header_line.length
226
+
227
+ # Write rows
228
+ rows.each do |row|
229
+ file.puts row.map.with_index { |cell, i| cell.to_s.ljust(widths[i]) }.join(' ')
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ prompt.ok("Results saved to: #{filename}")
236
+ rescue StandardError => e
237
+ prompt.error("Failed to save file: #{e.message}")
238
+ end
239
+
240
+ def output_results(results, format: nil)
241
+ format ||= options[:format] || 'table'
242
+ filtered_results = filter_results(results)
243
+
244
+ case format
245
+ when 'json'
246
+ puts JSON.pretty_generate(serialize_results(filtered_results))
247
+ when 'csv'
248
+ output_csv(filtered_results)
249
+ when 'table'
250
+ output_table(filtered_results)
251
+ end
252
+ end
253
+
254
+ def filter_results(results)
255
+ return results if options[:errors]
256
+
257
+ results.reject { |result| result&.status == :error }
258
+ end
259
+
260
+ def serialize_results(results)
261
+ results.map do |result|
262
+ case result
263
+ when Core::PoopEventResult
264
+ {
265
+ type: result.type,
266
+ status: result.status,
267
+ sender: result.message.sender,
268
+ message_timestamp: result.message.timestamp.iso8601,
269
+ poop_time: result.poop_time&.iso8601,
270
+ shift: result.shift,
271
+ timezone: result.timezone,
272
+ content: result.message.content,
273
+ error_details: result.error_details
274
+ }
275
+ when Core::ToiletShiftResult
276
+ {
277
+ type: result.type,
278
+ status: result.status,
279
+ sender: result.message.sender,
280
+ message_timestamp: result.message.timestamp.iso8601,
281
+ shift: result.shift,
282
+ effective_time: result.effective_time&.iso8601,
283
+ content: result.message.content,
284
+ error_details: result.error_details
285
+ }
286
+ else
287
+ {
288
+ type: result&.type,
289
+ status: result&.status,
290
+ content: result&.original_content || result&.message&.content,
291
+ error_details: result&.error_details
292
+ }
293
+ end
294
+ end
295
+ end
296
+
297
+ def output_csv(results)
298
+ CSV($stdout) do |csv|
299
+ csv << ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
300
+
301
+ results.each do |result|
302
+ next unless result
303
+
304
+ row = [
305
+ result.type,
306
+ result.status,
307
+ result.message&.sender,
308
+ result.message&.timestamp&.iso8601,
309
+ extract_event_time(result)&.iso8601,
310
+ extract_shift(result),
311
+ result.message&.content
312
+ ]
313
+ csv << row
314
+ end
315
+ end
316
+ end
317
+
318
+ def output_table(results)
319
+ return puts 'No results found.' if results.empty?
320
+
321
+ headers = ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
322
+ rows = results.filter_map do |result|
323
+ next unless result
324
+
325
+ [
326
+ result.type || 'unknown',
327
+ result.status || 'unknown',
328
+ result.message&.sender || 'unknown',
329
+ result.message&.timestamp&.strftime('%Y-%m-%d %H:%M:%S') || 'unknown',
330
+ extract_event_time(result)&.strftime('%Y-%m-%d %H:%M:%S') || 'unknown',
331
+ extract_shift(result) || '0',
332
+ (result.message&.content || '')[0..50]
333
+ ]
334
+ end
335
+
336
+ # Calculate column widths
337
+ widths = headers.map.with_index do |header, i|
338
+ [header.length, *rows.map { |row| row[i].to_s.length }].max
339
+ end
340
+
341
+ # Print header
342
+ header_line = headers.map.with_index { |h, i| h.ljust(widths[i]) }.join(' ')
343
+ puts header_line
344
+ puts '-' * header_line.length
345
+
346
+ # Print rows
347
+ rows.each do |row|
348
+ puts row.map.with_index { |cell, i| cell.to_s.ljust(widths[i]) }.join(' ')
349
+ end
350
+ end
351
+
352
+ def extract_event_time(result)
353
+ case result
354
+ when Core::PoopEventResult
355
+ result.poop_time
356
+ when Core::ToiletShiftResult
357
+ result.effective_time
358
+ else
359
+ result.message&.timestamp
360
+ end
361
+ end
362
+
363
+ def extract_shift(result)
364
+ case result
365
+ when Core::PoopEventResult, Core::ToiletShiftResult
366
+ result.shift
367
+ end
368
+ end
369
+
370
+ def show_statistics(results, total_lines)
371
+ filtered_results = filter_results(results)
372
+ error_results = results.select { |r| r&.status == :error }
373
+
374
+ # Calculate toilet-tracking relevant lines (successful + errors)
375
+ toilet_relevant_lines = filtered_results.size + error_results.size
376
+
377
+ puts '📊 Parsing Statistics'
378
+ puts '=' * 50
379
+ puts "Total chat lines: #{total_lines}"
380
+ puts "Toilet-tracking messages: #{toilet_relevant_lines}"
381
+ puts "Successfully parsed: #{filtered_results.size}"
382
+ puts "Parse errors: #{error_results.size}"
383
+ puts "Toilet tracking success rate: #{((filtered_results.size.to_f / toilet_relevant_lines) * 100).round(2)}%" if toilet_relevant_lines.positive?
384
+ puts "Overall relevance: #{((toilet_relevant_lines.to_f / total_lines) * 100).round(2)}%"
385
+ puts
386
+
387
+ if filtered_results.any?
388
+ types = filtered_results.group_by(&:type)
389
+ puts 'Message types:'
390
+ types.each do |type, messages|
391
+ puts " #{type}: #{messages.size}"
392
+ end
393
+ puts
394
+
395
+ senders = filtered_results.filter_map { |r| r.message&.sender }.tally
396
+ puts 'Top senders:'
397
+ senders.sort_by { |_, count| -count }.first(5).each do |sender, count|
398
+ puts " #{sender}: #{count}"
399
+ end
400
+ end
401
+
402
+ return unless error_results.any?
403
+
404
+ puts
405
+ puts 'Common toilet-tracking errors:'
406
+ error_types = error_results.filter_map(&:error_details).tally
407
+ error_types.sort_by { |_, count| -count }.first(3).each do |error, count|
408
+ puts " #{error[0..80]}...: #{count}"
409
+ end
410
+ end
411
+
412
+ def error(message)
413
+ warn "Error: #{message}"
414
+ end
415
+ end
416
+ end
417
+
418
+ # Run CLI if this file is executed directly
419
+ ToiletTracker::CLI.start(ARGV) if __FILE__ == $PROGRAM_NAME || File.basename($PROGRAM_NAME) == 'toilet_tracker'
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/time'
4
+
5
+ module ToiletTracker
6
+ class Configuration
7
+ attr_accessor :default_timezone, :max_shift_hours, :message_patterns, :date_formats
8
+
9
+ def initialize
10
+ @default_timezone = 'UTC'
11
+ @max_shift_hours = 12
12
+ @message_patterns = {
13
+ shift_set: /🚽\s*([+-]?\d+)$/,
14
+ shift_set_at_time: /🚽\s*([+-]?\d+)\s+(.+)$/,
15
+ timezone_set: %r{🚽\s*([A-Za-z_/][A-Za-z0-9_/+-]*)$},
16
+ event_now: /💩$/,
17
+ event_now_shifted: /💩\s*([+-]?\d+)$/,
18
+ event_at_time: %r{💩\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
19
+ event_at_time_shifted: %r{💩\s*([+-]?\d+)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)},
20
+ event_in_timezone: %r{💩\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s*$},
21
+ event_in_timezone_at_time: %r{💩\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$}
22
+ }
23
+ @date_formats = [
24
+ '%Y-%m-%d %H:%M:%S',
25
+ '%Y-%m-%d %H:%M',
26
+ '%Y/%m/%d %H:%M:%S',
27
+ '%Y/%m/%d %H:%M'
28
+ ]
29
+ end
30
+
31
+ def default_timezone_object
32
+ ActiveSupport::TimeZone[@default_timezone] ||
33
+ (raise Core::InvalidTimezone, @default_timezone)
34
+ end
35
+
36
+ def valid_shift?(shift)
37
+ shift.is_a?(Integer) && shift.abs <= max_shift_hours
38
+ end
39
+
40
+ def whatsapp_message_pattern
41
+ @whatsapp_message_pattern ||= %r{\[(?<day>\d{2})/(?<month>\d{2})/(?<year>\d{2}),\s*(?<time>\d{2}:\d{2}:\d{2})\]\s*(?<sender>[^:]+):\s*(?<content>.+?)(?:\s*‎<This message was edited>)?$}
42
+ end
43
+
44
+ def edited_message_suffix
45
+ '‎<This message was edited>'
46
+ end
47
+ end
48
+
49
+ class << self
50
+ attr_writer :configuration
51
+
52
+ def configuration
53
+ @configuration ||= Configuration.new
54
+ end
55
+
56
+ def configure
57
+ yield(configuration)
58
+ end
59
+
60
+ def reset_configuration!
61
+ @configuration = Configuration.new
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Core
5
+ class Error < StandardError; end
6
+
7
+ class ParseError < Error; end
8
+
9
+ class InvalidMessageFormat < ParseError
10
+ attr_reader :message_content, :expected_format
11
+
12
+ def initialize(message_content, expected_format = nil)
13
+ @message_content = message_content
14
+ @expected_format = expected_format
15
+ super(build_error_message)
16
+ end
17
+
18
+ private
19
+
20
+ def build_error_message
21
+ msg = "Invalid message format: #{message_content.inspect}"
22
+ msg += " (expected: #{expected_format})" if expected_format
23
+ msg
24
+ end
25
+ end
26
+
27
+ class InvalidTimezone < ParseError
28
+ attr_reader :timezone_string
29
+
30
+ def initialize(timezone_string)
31
+ @timezone_string = timezone_string
32
+ super("Invalid timezone: #{timezone_string.inspect}")
33
+ end
34
+ end
35
+
36
+ class InvalidShift < ParseError
37
+ attr_reader :shift_value
38
+
39
+ def initialize(shift_value)
40
+ @shift_value = shift_value
41
+ super("Invalid shift value: #{shift_value.inspect} (must be between -12 and +12)")
42
+ end
43
+ end
44
+
45
+ class InvalidDateTime < ParseError
46
+ attr_reader :datetime_string
47
+
48
+ def initialize(datetime_string)
49
+ @datetime_string = datetime_string
50
+ super("Invalid date/time format: #{datetime_string.inspect}")
51
+ end
52
+ end
53
+
54
+ class ConfigurationError < Error; end
55
+ end
56
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'active_support/core_ext/time'
5
+
6
+ module ToiletTracker
7
+ module Core
8
+ Message = Data.define(:timestamp, :sender, :content) do
9
+ def initialize(timestamp:, sender:, content:)
10
+ parsed_timestamp = case timestamp
11
+ when Time, ActiveSupport::TimeWithZone
12
+ timestamp
13
+ when String
14
+ Time.parse(timestamp)
15
+ else
16
+ raise ArgumentError, "Invalid timestamp type: #{timestamp.class}"
17
+ end
18
+
19
+ super(
20
+ timestamp: parsed_timestamp,
21
+ sender: sender.to_s.strip,
22
+ content: content.to_s
23
+ )
24
+ end
25
+
26
+ def valid?
27
+ timestamp.is_a?(Time) &&
28
+ !sender.empty? &&
29
+ !content.empty?
30
+ end
31
+
32
+ def age_in_days
33
+ (Time.current - timestamp) / 1.day
34
+ end
35
+
36
+ def recent?(days: 30)
37
+ age_in_days <= days
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Core
5
+ # Base class for all parse results
6
+ ParseResult = Data.define(:type, :status, :message, :error_details, :original_content) do
7
+ def initialize(type:, status:, message:, error_details: nil, original_content: nil)
8
+ super
9
+ end
10
+
11
+ def success?
12
+ status == :ok
13
+ end
14
+
15
+ def error?
16
+ status == :error
17
+ end
18
+
19
+ def alert?
20
+ status == :alert
21
+ end
22
+ end
23
+
24
+ # Result for toilet shift settings
25
+ ToiletShiftResult = Data.define(:type, :status, :message, :shift, :effective_time, :error_details) do
26
+ def initialize(type:, status:, message:, shift: nil, effective_time: nil, error_details: nil)
27
+ super
28
+ end
29
+ end
30
+
31
+ # Result for poop events
32
+ PoopEventResult = Data.define(:type, :status, :message, :shift, :poop_time, :timezone, :error_details) do
33
+ def initialize(type:, status:, message:, shift: nil, poop_time: nil, timezone: nil, error_details: nil)
34
+ super
35
+ end
36
+ end
37
+
38
+ # Result for timezone settings
39
+ TimezoneResult = Data.define(:type, :status, :message, :timezone, :shift_hours, :error_details) do
40
+ def initialize(type:, status:, message:, timezone: nil, shift_hours: nil, error_details: nil)
41
+ super
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToiletTracker
4
+ module Core
5
+ Result = Data.define(:success?, :value, :error) do
6
+ def self.success(value)
7
+ new(success?: true, value: value, error: nil)
8
+ end
9
+
10
+ def self.failure(error)
11
+ new(success?: false, value: nil, error: error)
12
+ end
13
+
14
+ def failure?
15
+ !success?
16
+ end
17
+
18
+ def then
19
+ return self if failure?
20
+
21
+ begin
22
+ result = yield(value)
23
+ result.is_a?(Result) ? result : Result.success(result)
24
+ rescue StandardError => e
25
+ Result.failure(e)
26
+ end
27
+ end
28
+
29
+ def map
30
+ return self if failure?
31
+
32
+ begin
33
+ Result.success(yield(value))
34
+ rescue StandardError => e
35
+ Result.failure(e)
36
+ end
37
+ end
38
+
39
+ def value!
40
+ raise error if failure?
41
+
42
+ value
43
+ end
44
+ end
45
+ end
46
+ end