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.
- checksums.yaml +7 -0
- data/.overcommit.yml +31 -0
- data/.rspec +3 -0
- data/.rubocop.yml +37 -0
- data/.standard.yml +2 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/PARSING_SPEC.md +361 -0
- data/README.md +253 -0
- data/Rakefile +12 -0
- data/STYLE_GUIDE.md +377 -0
- data/exe/toilet_tracker +419 -0
- data/lib/toilet_tracker/configuration.rb +64 -0
- data/lib/toilet_tracker/core/errors.rb +56 -0
- data/lib/toilet_tracker/core/message.rb +41 -0
- data/lib/toilet_tracker/core/parse_result.rb +45 -0
- data/lib/toilet_tracker/core/result.rb +46 -0
- data/lib/toilet_tracker/parsers/base_parser.rb +60 -0
- data/lib/toilet_tracker/parsers/message_parser.rb +209 -0
- data/lib/toilet_tracker/parsers/whatsapp_parser.rb +62 -0
- data/lib/toilet_tracker/services/analysis_service.rb +212 -0
- data/lib/toilet_tracker/services/shift_service.rb +89 -0
- data/lib/toilet_tracker/services/timezone_service.rb +93 -0
- data/lib/toilet_tracker/utils/zip_handler.rb +85 -0
- data/lib/toilet_tracker/version.rb +5 -0
- data/lib/toilet_tracker.rb +36 -0
- data/sig/toilet_tracker.rbs +4 -0
- metadata +159 -0
data/exe/toilet_tracker
ADDED
|
@@ -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
|