toilet_tracker 0.1.0 β 0.2.1
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 +4 -4
- data/.rubocop.yml +2 -37
- data/CHANGELOG.md +37 -0
- data/PARSING_SPEC.md +32 -2
- data/README.md +5 -5
- data/exe/toilet_tracker +8 -8
- data/lib/toilet_tracker/configuration.rb +40 -17
- data/lib/toilet_tracker/core/message.rb +6 -6
- data/lib/toilet_tracker/core/parse_result.rb +4 -4
- data/lib/toilet_tracker/parsers/base_parser.rb +65 -7
- data/lib/toilet_tracker/parsers/message_parser.rb +102 -35
- data/lib/toilet_tracker/parsers/whatsapp_parser.rb +5 -5
- data/lib/toilet_tracker/services/shift_service.rb +2 -2
- data/lib/toilet_tracker/services/timezone_service.rb +4 -4
- data/lib/toilet_tracker/utils/zip_handler.rb +8 -8
- data/lib/toilet_tracker/version.rb +1 -1
- data/lib/toilet_tracker.rb +14 -14
- metadata +17 -6
- data/.standard.yml +0 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e179c9943225797758be0ca803a1dcde9ee9f7b98ee7466d74cb208a49040b8
|
|
4
|
+
data.tar.gz: ea5b5ab8fb0e4da1ca4a8e7e48575bc72a9a967769c375e52e11dcfab3dd4801
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33081c85110a522542ae3c50209cbe5a1d302bb34bb513bc4c6118daaabf0093673e37695a7200818ed77d5e37bd725e22074a7898cd82f63df9518c105beaf9
|
|
7
|
+
data.tar.gz: 0ebf346d3c2afde44224885c4879bad74e1d29203c7c2f567fc573fbc989e48eee71f613257707df3e4b4359a861356111cd1e2395f9035d573259de6d0579c8
|
data/.rubocop.yml
CHANGED
|
@@ -1,37 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
SuggestExtensions: false
|
|
4
|
-
|
|
5
|
-
# Disable metrics cops for now
|
|
6
|
-
Metrics/BlockLength:
|
|
7
|
-
Enabled: false
|
|
8
|
-
|
|
9
|
-
Metrics/MethodLength:
|
|
10
|
-
Enabled: false
|
|
11
|
-
|
|
12
|
-
Metrics/AbcSize:
|
|
13
|
-
Enabled: false
|
|
14
|
-
|
|
15
|
-
Metrics/CyclomaticComplexity:
|
|
16
|
-
Enabled: false
|
|
17
|
-
|
|
18
|
-
Metrics/ClassLength:
|
|
19
|
-
Enabled: false
|
|
20
|
-
|
|
21
|
-
Metrics/PerceivedComplexity:
|
|
22
|
-
Enabled: false
|
|
23
|
-
|
|
24
|
-
# Disable documentation requirement
|
|
25
|
-
Style/Documentation:
|
|
26
|
-
Enabled: false
|
|
27
|
-
|
|
28
|
-
# Increase line length limit or disable
|
|
29
|
-
Layout/LineLength:
|
|
30
|
-
Max: 200
|
|
31
|
-
|
|
32
|
-
# Disable remaining Lint cops that need manual fixes
|
|
33
|
-
Lint/DuplicateBranch:
|
|
34
|
-
Enabled: false
|
|
35
|
-
|
|
36
|
-
Lint/UnreachableLoop:
|
|
37
|
-
Enabled: false
|
|
1
|
+
inherit_gem:
|
|
2
|
+
rubocop-rails-omakase: rubocop.yml
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2025-12-18
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Update activesupport to 8.1.1 (from 8.0.2.1)
|
|
7
|
+
- Update rubyzip to 3.2.2 (from 3.0.1)
|
|
8
|
+
- Update rake to 13.3.1 (from 13.3.0)
|
|
9
|
+
- Update rspec to 3.13.2 (from 3.13.1)
|
|
10
|
+
- Update overcommit to 0.68.0 (from 0.67.1)
|
|
11
|
+
- Relax version constraints for activesupport and rubyzip
|
|
12
|
+
|
|
13
|
+
## [0.2.0] - 2025-12-18
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Metadata emoji support for both π© and π½ commands
|
|
17
|
+
- Format: `[command emoji] [metadata emojis...] [shift/timezone] [time]`
|
|
18
|
+
- Metadata stored as array of individual emoji strings
|
|
19
|
+
- New `metadata` field on `PoopEventResult` and `ToiletShiftResult`
|
|
20
|
+
- `BaseParser#parse_metadata` and `#metadata_emoji?` helper methods
|
|
21
|
+
- Comprehensive test suite for metadata parsing (28 new tests)
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- All regex patterns updated to capture optional metadata group
|
|
25
|
+
- Result objects now include `metadata: []` by default (backward compatible)
|
|
26
|
+
|
|
27
|
+
## [0.1.1] - 2025-12-18
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Fractional timezone shift support (`+5:30` β 5.5 hours)
|
|
31
|
+
- Time-only patterns for events and settings (`π© 14:30`, `π½ +5:30 14:30`)
|
|
32
|
+
- Timezone-to-shift conversion (returns calculated shift instead of timezone name)
|
|
33
|
+
- New regex patterns with example comments for clarity
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- `Configuration#valid_shift?` now accepts Float values
|
|
37
|
+
- `TimezoneService#calculate_shift` returns Float for fractional hours
|
|
38
|
+
- Replaced StandardRB with RuboCop Omakase for linting
|
|
39
|
+
|
|
3
40
|
## [0.1.0] - 2025-06-26
|
|
4
41
|
|
|
5
42
|
- Initial release
|
data/PARSING_SPEC.md
CHANGED
|
@@ -20,6 +20,24 @@ All WhatsApp messages follow this format:
|
|
|
20
20
|
- **Functional Shifts**: Applied to event timestamps (patterns 1, 2, 5)
|
|
21
21
|
- **Decorative Shifts**: For statistics only, do not affect timestamps (patterns 7, 9)
|
|
22
22
|
|
|
23
|
+
### Metadata Emojis
|
|
24
|
+
Both π© and π½ commands support optional metadata emojis placed between the command emoji and the time/shift specifications. Metadata emojis are stored as an array and can be used for additional categorization or tagging of events.
|
|
25
|
+
|
|
26
|
+
**Format**: `[command emoji] [metadata emojis...] [shift/timezone] [time]`
|
|
27
|
+
|
|
28
|
+
**Examples**:
|
|
29
|
+
- `π© π©Έ` β Event with metadata `["π©Έ"]`
|
|
30
|
+
- `π© π©Έπ§π₯` β Event with metadata `["π©Έ", "π§", "π₯"]`
|
|
31
|
+
- `π© π©Έ +2` β Event with metadata `["π©Έ"]` and shift `+2`
|
|
32
|
+
- `π½ βοΈπ +5:30` β Setting with metadata `["βοΈ", "π"]` and shift `+5:30`
|
|
33
|
+
|
|
34
|
+
**Behavior**:
|
|
35
|
+
- Metadata is any emoji placed after the command emoji but before time specs
|
|
36
|
+
- Emojis are extracted as individual grapheme clusters (supports complex emojis)
|
|
37
|
+
- Command emojis (π©, π½) are excluded from metadata
|
|
38
|
+
- Stored as an array of strings in result objects
|
|
39
|
+
- Does not affect parsing of shifts, timezones, or times
|
|
40
|
+
|
|
23
41
|
## Supported Parsing Patterns
|
|
24
42
|
|
|
25
43
|
### WhatsApp Message Format
|
|
@@ -262,8 +280,9 @@ When timezone shifts are set retroactively:
|
|
|
262
280
|
type: :shift_set | :shift_set_at_time | :timezone_set,
|
|
263
281
|
status: :ok | :alert | :error,
|
|
264
282
|
message: Core::Message,
|
|
265
|
-
shift: Integer,
|
|
283
|
+
shift: Integer | Float,
|
|
266
284
|
effective_time: Time,
|
|
285
|
+
metadata: Array<String>,
|
|
267
286
|
error_details: String | nil
|
|
268
287
|
}
|
|
269
288
|
```
|
|
@@ -275,9 +294,10 @@ When timezone shifts are set retroactively:
|
|
|
275
294
|
:event_in_timezone | :event_in_timezone_at_time,
|
|
276
295
|
status: :ok | :alert | :error,
|
|
277
296
|
message: Core::Message,
|
|
278
|
-
shift: Integer | nil,
|
|
297
|
+
shift: Integer | Float | nil,
|
|
279
298
|
poop_time: Time,
|
|
280
299
|
timezone: String | nil,
|
|
300
|
+
metadata: Array<String>,
|
|
281
301
|
error_details: String | nil
|
|
282
302
|
}
|
|
283
303
|
```
|
|
@@ -342,6 +362,16 @@ When timezone shifts are set retroactively:
|
|
|
342
362
|
[02/01/25, 15:00:00] Bob: π© PST 2025-01-02 12:00:00
|
|
343
363
|
```
|
|
344
364
|
|
|
365
|
+
### Valid Examples with Metadata
|
|
366
|
+
```
|
|
367
|
+
[01/01/25, 08:30:00] Alice: π© π©Έ # metadata: ["π©Έ"]
|
|
368
|
+
[01/01/25, 10:00:00] Alice: π© π©Έπ§ +2 # metadata: ["π©Έ", "π§"], shift: 2
|
|
369
|
+
[01/01/25, 12:00:00] Alice: π© π§± 14:30 # metadata: ["π§±"], time: 14:30
|
|
370
|
+
[01/01/25, 14:00:00] Alice: π© π₯ +2 2025-01-01 13:00 # metadata + shift + time
|
|
371
|
+
[02/01/25, 09:00:00] Bob: π½ βοΈ +5:30 # metadata: ["βοΈ"], shift: 5.5
|
|
372
|
+
[02/01/25, 10:00:00] Bob: π½ π βοΈ Europe/Rome # metadata: ["π ", "βοΈ"], timezone
|
|
373
|
+
```
|
|
374
|
+
|
|
345
375
|
### Invalid Examples
|
|
346
376
|
```
|
|
347
377
|
[01/01/25, 10:00:00] Alice: π½ +25 # Exceeds max_shift_hours
|
data/README.md
CHANGED
|
@@ -16,7 +16,7 @@ A modern Ruby gem for parsing WhatsApp messages containing toilet tracking data
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
18
18
|
- **Ruby 3.4+** - Uses modern Ruby features for optimal performance
|
|
19
|
-
- **
|
|
19
|
+
- **RuboCop Omakase** - Opinionated Ruby styling from the Rails team
|
|
20
20
|
|
|
21
21
|
## Installation
|
|
22
22
|
|
|
@@ -186,11 +186,11 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
|
186
186
|
# Run tests
|
|
187
187
|
bundle exec rspec
|
|
188
188
|
|
|
189
|
-
# Check code style (
|
|
190
|
-
bundle exec
|
|
189
|
+
# Check code style (RuboCop)
|
|
190
|
+
bundle exec rubocop
|
|
191
191
|
|
|
192
192
|
# Auto-fix style issues
|
|
193
|
-
bundle exec
|
|
193
|
+
bundle exec rubocop -a
|
|
194
194
|
|
|
195
195
|
# Install gem locally
|
|
196
196
|
bundle exec rake install
|
|
@@ -198,7 +198,7 @@ bundle exec rake install
|
|
|
198
198
|
|
|
199
199
|
### Code Quality
|
|
200
200
|
|
|
201
|
-
This project uses **
|
|
201
|
+
This project uses **RuboCop Omakase** for opinionated Ruby linting and formatting. Omakase provides the Rails team's preferred styling rules.
|
|
202
202
|
|
|
203
203
|
Key principles:
|
|
204
204
|
- **Ruby 3.4+ features** - Modern syntax and performance optimizations
|
data/exe/toilet_tracker
CHANGED
|
@@ -95,7 +95,7 @@ module ToiletTracker
|
|
|
95
95
|
|
|
96
96
|
when :manual_entry
|
|
97
97
|
message = prompt.ask('Enter WhatsApp message:')
|
|
98
|
-
results = ToiletTracker.parse_lines([message])
|
|
98
|
+
results = ToiletTracker.parse_lines([ message ])
|
|
99
99
|
handle_interactive_results(results, prompt)
|
|
100
100
|
|
|
101
101
|
when :exit
|
|
@@ -103,7 +103,7 @@ module ToiletTracker
|
|
|
103
103
|
break
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
prompt.keypress('Press any key to continue...', keys: [:return])
|
|
106
|
+
prompt.keypress('Press any key to continue...', keys: [ :return ])
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
@@ -177,7 +177,7 @@ module ToiletTracker
|
|
|
177
177
|
when 'csv'
|
|
178
178
|
require 'csv'
|
|
179
179
|
CSV.open(filename, 'w') do |csv|
|
|
180
|
-
csv << ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
|
|
180
|
+
csv << [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
|
|
181
181
|
filter_results(results).each do |result|
|
|
182
182
|
next unless result
|
|
183
183
|
|
|
@@ -199,7 +199,7 @@ module ToiletTracker
|
|
|
199
199
|
if filtered_results.empty?
|
|
200
200
|
file.puts 'No results found.'
|
|
201
201
|
else
|
|
202
|
-
headers = ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
|
|
202
|
+
headers = [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
|
|
203
203
|
rows = filtered_results.filter_map do |result|
|
|
204
204
|
next unless result
|
|
205
205
|
|
|
@@ -216,7 +216,7 @@ module ToiletTracker
|
|
|
216
216
|
|
|
217
217
|
# Calculate column widths
|
|
218
218
|
widths = headers.map.with_index do |header, i|
|
|
219
|
-
[header.length, *rows.map { |row| row[i].to_s.length }].max
|
|
219
|
+
[ header.length, *rows.map { |row| row[i].to_s.length } ].max
|
|
220
220
|
end
|
|
221
221
|
|
|
222
222
|
# Write header
|
|
@@ -296,7 +296,7 @@ module ToiletTracker
|
|
|
296
296
|
|
|
297
297
|
def output_csv(results)
|
|
298
298
|
CSV($stdout) do |csv|
|
|
299
|
-
csv << ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
|
|
299
|
+
csv << [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
|
|
300
300
|
|
|
301
301
|
results.each do |result|
|
|
302
302
|
next unless result
|
|
@@ -318,7 +318,7 @@ module ToiletTracker
|
|
|
318
318
|
def output_table(results)
|
|
319
319
|
return puts 'No results found.' if results.empty?
|
|
320
320
|
|
|
321
|
-
headers = ['Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content']
|
|
321
|
+
headers = [ 'Type', 'Status', 'Sender', 'Message Time', 'Event Time', 'Shift', 'Content' ]
|
|
322
322
|
rows = results.filter_map do |result|
|
|
323
323
|
next unless result
|
|
324
324
|
|
|
@@ -335,7 +335,7 @@ module ToiletTracker
|
|
|
335
335
|
|
|
336
336
|
# Calculate column widths
|
|
337
337
|
widths = headers.map.with_index do |header, i|
|
|
338
|
-
[header.length, *rows.map { |row| row[i].to_s.length }].max
|
|
338
|
+
[ header.length, *rows.map { |row| row[i].to_s.length } ].max
|
|
339
339
|
end
|
|
340
340
|
|
|
341
341
|
# Print header
|
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "active_support/time"
|
|
4
4
|
|
|
5
5
|
module ToiletTracker
|
|
6
6
|
class Configuration
|
|
7
7
|
attr_accessor :default_timezone, :max_shift_hours, :message_patterns, :date_formats
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
|
-
@default_timezone =
|
|
10
|
+
@default_timezone = "UTC"
|
|
11
11
|
@max_shift_hours = 12
|
|
12
|
+
# Capture group 1 is always metadata emojis, subsequent groups are pattern-specific
|
|
12
13
|
@message_patterns = {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
# π© π©Έ 14:30
|
|
15
|
+
event_at_time_only: /π©\s*([^+\-\dA-Za-z\s]*)\s*(\d{1,2}:\d{2})$/,
|
|
16
|
+
# π© π©Έ +5 14:30, π© π©Έ +5:30 14:30
|
|
17
|
+
event_at_time_only_shifted: /π©\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{1,2}:\d{2})$/,
|
|
18
|
+
# π© π©Έ Asia/Kolkata 14:30
|
|
19
|
+
event_in_timezone_at_time_only: %r{π©\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{1,2}:\d{2})$},
|
|
20
|
+
# π½ π +5 14:30, π½ π +5:30 14:30
|
|
21
|
+
shift_set_at_time_only: /π½\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{1,2}:\d{2})$/,
|
|
22
|
+
# π½ π Asia/Kolkata 14:30
|
|
23
|
+
timezone_set_at_time_only: %r{π½\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s+(\d{1,2}:\d{2})$},
|
|
24
|
+
# π½ π +2, π½ π -3, π½ π +5:30
|
|
25
|
+
shift_set: /π½\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)$/,
|
|
26
|
+
# π½ π +2 2025-01-15 14:30, π½ π +5:30 2025-01-15 14:30
|
|
27
|
+
shift_set_at_time: %r{π½\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
|
|
28
|
+
# π½ π Europe/Rome, π½ π PST
|
|
29
|
+
timezone_set: %r{π½\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)$},
|
|
30
|
+
# π½ π Asia/Kolkata 2025-01-15 14:30
|
|
31
|
+
timezone_set_at_time: %r{π½\s*([^+\-\dA-Za-z\s]*)\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})?)$},
|
|
32
|
+
# π©, π© π©Έ
|
|
33
|
+
event_now: /π©\s*([^+\-\dA-Za-z\s]*)$/,
|
|
34
|
+
# π© π©Έ +2, π© π©Έ -1, π© π©Έ +5:30
|
|
35
|
+
event_now_shifted: /π©\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)$/,
|
|
36
|
+
# π© π©Έ 2025-01-15 14:30
|
|
37
|
+
event_at_time: %r{π©\s*([^+\-\dA-Za-z\s]*)\s*(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
|
|
38
|
+
# π© π©Έ +2 2025-01-15 14:30, π© π©Έ +5:30 2025-01-15 14:30
|
|
39
|
+
event_at_time_shifted: %r{π©\s*([^+\-\dA-Za-z\s]*)\s*([+-]?\d+(?::\d{2})?)\s+(\d{4}[-/]\d{1,2}[-/]\d{1,2}\s+\d{1,2}:\d{1,2}(?::\d{1,2})?)$},
|
|
40
|
+
# π© π©Έ Europe/Rome, π© π©Έ PST
|
|
41
|
+
event_in_timezone: %r{π©\s*([^+\-\dA-Za-z\s]*)\s*([A-Za-z_/][A-Za-z0-9_/+-]*)\s*$},
|
|
42
|
+
# π© π©Έ Asia/Kolkata 2025-01-15 14:30
|
|
43
|
+
event_in_timezone_at_time: %r{π©\s*([^+\-\dA-Za-z\s]*)\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
44
|
}
|
|
23
45
|
@date_formats = [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
"%Y-%m-%d %H:%M:%S",
|
|
47
|
+
"%Y-%m-%d %H:%M",
|
|
48
|
+
"%Y/%m/%d %H:%M:%S",
|
|
49
|
+
"%Y/%m/%d %H:%M",
|
|
50
|
+
"%H:%M"
|
|
28
51
|
]
|
|
29
52
|
end
|
|
30
53
|
|
|
@@ -34,7 +57,7 @@ module ToiletTracker
|
|
|
34
57
|
end
|
|
35
58
|
|
|
36
59
|
def valid_shift?(shift)
|
|
37
|
-
shift.is_a?(Integer) && shift.abs <= max_shift_hours
|
|
60
|
+
(shift.is_a?(Integer) || shift.is_a?(Float)) && shift.abs <= max_shift_hours
|
|
38
61
|
end
|
|
39
62
|
|
|
40
63
|
def whatsapp_message_pattern
|
|
@@ -42,7 +65,7 @@ module ToiletTracker
|
|
|
42
65
|
end
|
|
43
66
|
|
|
44
67
|
def edited_message_suffix
|
|
45
|
-
|
|
68
|
+
"β<This message was edited>"
|
|
46
69
|
end
|
|
47
70
|
end
|
|
48
71
|
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require "time"
|
|
4
|
+
require "active_support/core_ext/time"
|
|
5
5
|
|
|
6
6
|
module ToiletTracker
|
|
7
7
|
module Core
|
|
8
8
|
Message = Data.define(:timestamp, :sender, :content) do
|
|
9
9
|
def initialize(timestamp:, sender:, content:)
|
|
10
10
|
parsed_timestamp = case timestamp
|
|
11
|
-
|
|
11
|
+
when Time, ActiveSupport::TimeWithZone
|
|
12
12
|
timestamp
|
|
13
|
-
|
|
13
|
+
when String
|
|
14
14
|
Time.parse(timestamp)
|
|
15
|
-
|
|
15
|
+
else
|
|
16
16
|
raise ArgumentError, "Invalid timestamp type: #{timestamp.class}"
|
|
17
|
-
|
|
17
|
+
end
|
|
18
18
|
|
|
19
19
|
super(
|
|
20
20
|
timestamp: parsed_timestamp,
|
|
@@ -22,15 +22,15 @@ module ToiletTracker
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
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)
|
|
25
|
+
ToiletShiftResult = Data.define(:type, :status, :message, :shift, :effective_time, :metadata, :error_details) do
|
|
26
|
+
def initialize(type:, status:, message:, shift: nil, effective_time: nil, metadata: [], error_details: nil)
|
|
27
27
|
super
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
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)
|
|
32
|
+
PoopEventResult = Data.define(:type, :status, :message, :shift, :poop_time, :timezone, :metadata, :error_details) do
|
|
33
|
+
def initialize(type:, status:, message:, shift: nil, poop_time: nil, timezone: nil, metadata: [], error_details: nil)
|
|
34
34
|
super
|
|
35
35
|
end
|
|
36
36
|
end
|
|
@@ -10,23 +10,29 @@ module ToiletTracker
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def parse(input)
|
|
13
|
-
raise NotImplementedError,
|
|
13
|
+
raise NotImplementedError, "Subclasses must implement #parse"
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def valid?(input)
|
|
17
|
-
raise NotImplementedError,
|
|
17
|
+
raise NotImplementedError, "Subclasses must implement #valid?"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
protected
|
|
21
21
|
|
|
22
22
|
def clean_content(content)
|
|
23
|
-
content.to_s.gsub(config.edited_message_suffix,
|
|
23
|
+
content.to_s.gsub(config.edited_message_suffix, "").strip
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def parse_shift(shift_string)
|
|
27
27
|
return nil if shift_string.nil? || shift_string.empty?
|
|
28
28
|
|
|
29
|
-
shift
|
|
29
|
+
# Check if shift contains colon format (e.g., "+5:30", "-3:45")
|
|
30
|
+
shift = if shift_string.match?(/^[+-]?\d+:\d{2}$/)
|
|
31
|
+
parse_fractional_shift(shift_string)
|
|
32
|
+
else
|
|
33
|
+
Integer(shift_string)
|
|
34
|
+
end
|
|
35
|
+
|
|
30
36
|
raise Core::InvalidShift, shift_string unless config.valid_shift?(shift)
|
|
31
37
|
|
|
32
38
|
shift
|
|
@@ -34,26 +40,78 @@ module ToiletTracker
|
|
|
34
40
|
raise Core::InvalidShift, shift_string
|
|
35
41
|
end
|
|
36
42
|
|
|
37
|
-
|
|
43
|
+
# "+5:30" -> 5.5, "-3:45" -> -3.75, "+5:00" -> 5 (integer)
|
|
44
|
+
def parse_fractional_shift(shift_string)
|
|
45
|
+
match = shift_string.match(/^([+-])?(\d+):(\d{2})$/)
|
|
46
|
+
raise ArgumentError, "Invalid shift format: #{shift_string}" unless match
|
|
47
|
+
|
|
48
|
+
sign = (match[1] == "-") ? -1 : 1
|
|
49
|
+
hours = match[2].to_i
|
|
50
|
+
minutes = match[3].to_i
|
|
51
|
+
|
|
52
|
+
raise ArgumentError, "Invalid minutes: #{minutes}" if minutes > 59
|
|
53
|
+
|
|
54
|
+
decimal = hours + minutes / 60.0
|
|
55
|
+
minutes.zero? ? sign * hours : sign * decimal
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse_datetime(datetime_string, reference_message = nil)
|
|
38
59
|
return nil if datetime_string.nil? || datetime_string.empty?
|
|
39
60
|
|
|
61
|
+
time_only_result = try_parse_time_only(datetime_string, reference_message)
|
|
62
|
+
return time_only_result if time_only_result
|
|
63
|
+
|
|
40
64
|
config.date_formats.each do |format|
|
|
65
|
+
next if format == "%H:%M"
|
|
41
66
|
return config.default_timezone_object.strptime(datetime_string, format)
|
|
42
67
|
rescue ArgumentError
|
|
43
68
|
next
|
|
44
69
|
end
|
|
45
70
|
|
|
46
|
-
# Fallback to timezone-aware parse
|
|
47
71
|
config.default_timezone_object.parse(datetime_string)
|
|
48
72
|
rescue ArgumentError
|
|
49
73
|
raise Core::InvalidDateTime, datetime_string
|
|
50
74
|
end
|
|
51
75
|
|
|
76
|
+
def try_parse_time_only(datetime_string, reference_message)
|
|
77
|
+
return nil unless reference_message
|
|
78
|
+
return nil unless datetime_string.match?(/^\d{1,2}:\d{2}$/)
|
|
79
|
+
|
|
80
|
+
parsed_time = Time.strptime(datetime_string, "%H:%M")
|
|
81
|
+
date_part = reference_message.timestamp.to_date
|
|
82
|
+
|
|
83
|
+
config.default_timezone_object.local(
|
|
84
|
+
date_part.year,
|
|
85
|
+
date_part.month,
|
|
86
|
+
date_part.day,
|
|
87
|
+
parsed_time.hour,
|
|
88
|
+
parsed_time.min,
|
|
89
|
+
0
|
|
90
|
+
)
|
|
91
|
+
rescue ArgumentError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
52
95
|
def determine_status(event_time, message_time)
|
|
53
96
|
return :error unless event_time && message_time
|
|
54
97
|
|
|
55
98
|
time_diff = (message_time - event_time).abs
|
|
56
|
-
time_diff > 30.days ? :alert : :ok
|
|
99
|
+
(time_diff > 30.days) ? :alert : :ok
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parse_metadata(metadata_string)
|
|
103
|
+
return [] if metadata_string.nil? || metadata_string.strip.empty?
|
|
104
|
+
|
|
105
|
+
metadata_string.grapheme_clusters
|
|
106
|
+
.reject { |char| char.match?(/\s/) }
|
|
107
|
+
.select { |char| metadata_emoji?(char) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def metadata_emoji?(char)
|
|
111
|
+
return false if char.match?(/\A[\p{ASCII}]\z/)
|
|
112
|
+
return false if %w[π© π½].include?(char)
|
|
113
|
+
|
|
114
|
+
char.match?(/\p{Emoji}/) && !char.match?(/\A\d\z/)
|
|
57
115
|
end
|
|
58
116
|
end
|
|
59
117
|
end
|
|
@@ -3,17 +3,20 @@
|
|
|
3
3
|
module ToiletTracker
|
|
4
4
|
module Parsers
|
|
5
5
|
class MessageParser < BaseParser
|
|
6
|
+
attr_accessor :current_message
|
|
7
|
+
|
|
6
8
|
def parse(message)
|
|
7
|
-
return Core::Result.failure(ArgumentError.new(
|
|
8
|
-
return Core::Result.failure(ArgumentError.new(
|
|
9
|
+
return Core::Result.failure(ArgumentError.new("Message required")) unless message.is_a?(Core::Message)
|
|
10
|
+
return Core::Result.failure(ArgumentError.new("Invalid message")) unless message.valid?
|
|
9
11
|
|
|
12
|
+
@current_message = message
|
|
10
13
|
content = message.content.strip
|
|
11
14
|
|
|
12
|
-
# Try to match patterns
|
|
13
15
|
matched_result = try_pattern_matching(content, message)
|
|
16
|
+
@current_message = nil
|
|
14
17
|
return matched_result if matched_result
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
@current_message = nil
|
|
17
20
|
handle_unmatched_content(content)
|
|
18
21
|
end
|
|
19
22
|
|
|
@@ -43,8 +46,8 @@ module ToiletTracker
|
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def handle_unmatched_content(content)
|
|
46
|
-
if content.include?(
|
|
47
|
-
error = Core::InvalidMessageFormat.new(content,
|
|
49
|
+
if content.include?("π½") || content.include?("π©")
|
|
50
|
+
error = Core::InvalidMessageFormat.new(content, "valid emoji pattern")
|
|
48
51
|
return Core::Result.failure(error)
|
|
49
52
|
end
|
|
50
53
|
|
|
@@ -53,32 +56,47 @@ module ToiletTracker
|
|
|
53
56
|
end
|
|
54
57
|
|
|
55
58
|
def parse_by_type(type, match, message)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
mapped_type = map_time_only_pattern(type)
|
|
60
|
+
|
|
61
|
+
case mapped_type
|
|
62
|
+
when :shift_set, :shift_set_at_time
|
|
63
|
+
parse_toilet_type(mapped_type, match, message)
|
|
64
|
+
when :timezone_set
|
|
65
|
+
parse_toilet_timezone(match, message)
|
|
66
|
+
when :timezone_set_at_time
|
|
67
|
+
parse_toilet_timezone_with_time(match, message)
|
|
59
68
|
when :event_now, :event_now_shifted, :event_at_time, :event_at_time_shifted,
|
|
60
69
|
:event_in_timezone, :event_in_timezone_at_time
|
|
61
|
-
parse_poop_type(
|
|
70
|
+
parse_poop_type(mapped_type, match, message)
|
|
62
71
|
else
|
|
63
72
|
raise Core::ParseError, "Unknown message type: #{type}"
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
|
|
76
|
+
def map_time_only_pattern(type)
|
|
77
|
+
case type
|
|
78
|
+
when :event_at_time_only then :event_at_time
|
|
79
|
+
when :event_at_time_only_shifted then :event_at_time_shifted
|
|
80
|
+
when :event_in_timezone_at_time_only then :event_in_timezone_at_time
|
|
81
|
+
when :shift_set_at_time_only then :shift_set_at_time
|
|
82
|
+
when :timezone_set_at_time_only then :timezone_set_at_time
|
|
83
|
+
else type
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
67
87
|
def parse_toilet_type(type, match, message)
|
|
68
88
|
case type
|
|
69
89
|
when :shift_set
|
|
70
90
|
parse_toilet_shift(match, message)
|
|
71
91
|
when :shift_set_at_time
|
|
72
92
|
parse_toilet_shift_with_time(match, message)
|
|
73
|
-
when :timezone_set
|
|
74
|
-
parse_toilet_timezone(match, message)
|
|
75
93
|
end
|
|
76
94
|
end
|
|
77
95
|
|
|
78
96
|
def parse_poop_type(type, match, message)
|
|
79
97
|
case type
|
|
80
98
|
when :event_now
|
|
81
|
-
parse_poop_live(message)
|
|
99
|
+
parse_poop_live(match, message)
|
|
82
100
|
when :event_now_shifted
|
|
83
101
|
parse_poop_live_shift(match, message)
|
|
84
102
|
when :event_at_time
|
|
@@ -93,20 +111,23 @@ module ToiletTracker
|
|
|
93
111
|
end
|
|
94
112
|
|
|
95
113
|
def parse_toilet_shift(match, message)
|
|
96
|
-
|
|
114
|
+
metadata = parse_metadata(match[1])
|
|
115
|
+
shift = parse_shift(match[2])
|
|
97
116
|
|
|
98
117
|
Core::ToiletShiftResult.new(
|
|
99
118
|
type: :shift_set,
|
|
100
119
|
status: :ok,
|
|
101
120
|
message: message,
|
|
102
121
|
shift: shift,
|
|
103
|
-
effective_time: message.timestamp
|
|
122
|
+
effective_time: message.timestamp,
|
|
123
|
+
metadata: metadata
|
|
104
124
|
)
|
|
105
125
|
end
|
|
106
126
|
|
|
107
127
|
def parse_toilet_shift_with_time(match, message)
|
|
108
|
-
|
|
109
|
-
|
|
128
|
+
metadata = parse_metadata(match[1])
|
|
129
|
+
shift = parse_shift(match[2])
|
|
130
|
+
effective_time = parse_datetime(match[3], @current_message)
|
|
110
131
|
|
|
111
132
|
status = determine_status(effective_time, message.timestamp)
|
|
112
133
|
|
|
@@ -115,45 +136,84 @@ module ToiletTracker
|
|
|
115
136
|
status: status,
|
|
116
137
|
message: message,
|
|
117
138
|
shift: shift,
|
|
118
|
-
effective_time: effective_time
|
|
139
|
+
effective_time: effective_time,
|
|
140
|
+
metadata: metadata
|
|
119
141
|
)
|
|
120
142
|
end
|
|
121
143
|
|
|
122
144
|
def parse_toilet_timezone(match, message)
|
|
123
|
-
|
|
145
|
+
metadata = parse_metadata(match[1])
|
|
146
|
+
timezone_str = match[2]
|
|
147
|
+
shift = calculate_shift_from_timezone(timezone_str)
|
|
124
148
|
|
|
125
|
-
Core::
|
|
149
|
+
Core::ToiletShiftResult.new(
|
|
126
150
|
type: :timezone_set,
|
|
127
151
|
status: :ok,
|
|
128
152
|
message: message,
|
|
129
|
-
|
|
153
|
+
shift: shift,
|
|
154
|
+
effective_time: message.timestamp,
|
|
155
|
+
metadata: metadata
|
|
130
156
|
)
|
|
131
157
|
end
|
|
132
158
|
|
|
133
|
-
def
|
|
159
|
+
def parse_toilet_timezone_with_time(match, message)
|
|
160
|
+
metadata = parse_metadata(match[1])
|
|
161
|
+
timezone_str = match[2]
|
|
162
|
+
datetime_str = match[3]
|
|
163
|
+
|
|
164
|
+
effective_time = parse_datetime(datetime_str, @current_message)
|
|
165
|
+
status = determine_status(effective_time, message.timestamp)
|
|
166
|
+
shift = calculate_shift_from_timezone(timezone_str)
|
|
167
|
+
|
|
168
|
+
Core::ToiletShiftResult.new(
|
|
169
|
+
type: :timezone_set_at_time,
|
|
170
|
+
status: status,
|
|
171
|
+
message: message,
|
|
172
|
+
shift: shift,
|
|
173
|
+
effective_time: effective_time,
|
|
174
|
+
metadata: metadata
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def calculate_shift_from_timezone(timezone_str)
|
|
179
|
+
tz_object = ActiveSupport::TimeZone[timezone_str]
|
|
180
|
+
raise Core::InvalidTimezone, timezone_str unless tz_object
|
|
181
|
+
|
|
182
|
+
default_tz = config.default_timezone_object
|
|
183
|
+
shift = (tz_object.utc_offset - default_tz.utc_offset) / 3600.0
|
|
184
|
+
(shift == shift.to_i) ? shift.to_i : shift
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def parse_poop_live(match, message)
|
|
188
|
+
metadata = parse_metadata(match[1])
|
|
189
|
+
|
|
134
190
|
Core::PoopEventResult.new(
|
|
135
191
|
type: :event_now,
|
|
136
192
|
status: :ok,
|
|
137
193
|
message: message,
|
|
138
194
|
shift: 0,
|
|
139
|
-
poop_time: message.timestamp
|
|
195
|
+
poop_time: message.timestamp,
|
|
196
|
+
metadata: metadata
|
|
140
197
|
)
|
|
141
198
|
end
|
|
142
199
|
|
|
143
200
|
def parse_poop_live_shift(match, message)
|
|
144
|
-
|
|
201
|
+
metadata = parse_metadata(match[1])
|
|
202
|
+
shift = parse_shift(match[2])
|
|
145
203
|
|
|
146
204
|
Core::PoopEventResult.new(
|
|
147
205
|
type: :event_now_shifted,
|
|
148
206
|
status: :ok,
|
|
149
207
|
message: message,
|
|
150
208
|
shift: shift,
|
|
151
|
-
poop_time: message.timestamp
|
|
209
|
+
poop_time: message.timestamp,
|
|
210
|
+
metadata: metadata
|
|
152
211
|
)
|
|
153
212
|
end
|
|
154
213
|
|
|
155
214
|
def parse_poop_past(match, message)
|
|
156
|
-
|
|
215
|
+
metadata = parse_metadata(match[1])
|
|
216
|
+
poop_time = parse_datetime(match[2], @current_message)
|
|
157
217
|
status = determine_status(poop_time, message.timestamp)
|
|
158
218
|
|
|
159
219
|
Core::PoopEventResult.new(
|
|
@@ -161,13 +221,15 @@ module ToiletTracker
|
|
|
161
221
|
status: status,
|
|
162
222
|
message: message,
|
|
163
223
|
shift: 0,
|
|
164
|
-
poop_time: poop_time
|
|
224
|
+
poop_time: poop_time,
|
|
225
|
+
metadata: metadata
|
|
165
226
|
)
|
|
166
227
|
end
|
|
167
228
|
|
|
168
229
|
def parse_poop_past_with_shift(match, message)
|
|
169
|
-
|
|
170
|
-
|
|
230
|
+
metadata = parse_metadata(match[1])
|
|
231
|
+
shift = parse_shift(match[2])
|
|
232
|
+
poop_time = parse_datetime(match[3], @current_message)
|
|
171
233
|
status = determine_status(poop_time, message.timestamp)
|
|
172
234
|
|
|
173
235
|
Core::PoopEventResult.new(
|
|
@@ -175,25 +237,29 @@ module ToiletTracker
|
|
|
175
237
|
status: status,
|
|
176
238
|
message: message,
|
|
177
239
|
shift: shift,
|
|
178
|
-
poop_time: poop_time
|
|
240
|
+
poop_time: poop_time,
|
|
241
|
+
metadata: metadata
|
|
179
242
|
)
|
|
180
243
|
end
|
|
181
244
|
|
|
182
245
|
def parse_poop_timezone(match, message)
|
|
183
|
-
|
|
246
|
+
metadata = parse_metadata(match[1])
|
|
247
|
+
timezone_str = match[2]
|
|
184
248
|
|
|
185
249
|
Core::PoopEventResult.new(
|
|
186
250
|
type: :event_in_timezone,
|
|
187
251
|
status: :ok,
|
|
188
252
|
message: message,
|
|
189
253
|
poop_time: message.timestamp,
|
|
190
|
-
timezone: timezone_str
|
|
254
|
+
timezone: timezone_str,
|
|
255
|
+
metadata: metadata
|
|
191
256
|
)
|
|
192
257
|
end
|
|
193
258
|
|
|
194
259
|
def parse_poop_timezone_with_time(match, message)
|
|
195
|
-
|
|
196
|
-
|
|
260
|
+
metadata = parse_metadata(match[1])
|
|
261
|
+
timezone_str = match[2]
|
|
262
|
+
poop_time = parse_datetime(match[3], @current_message)
|
|
197
263
|
status = determine_status(poop_time, message.timestamp)
|
|
198
264
|
|
|
199
265
|
Core::PoopEventResult.new(
|
|
@@ -201,7 +267,8 @@ module ToiletTracker
|
|
|
201
267
|
status: status,
|
|
202
268
|
message: message,
|
|
203
269
|
poop_time: poop_time,
|
|
204
|
-
timezone: timezone_str
|
|
270
|
+
timezone: timezone_str,
|
|
271
|
+
metadata: metadata
|
|
205
272
|
)
|
|
206
273
|
end
|
|
207
274
|
end
|
|
@@ -24,11 +24,11 @@ module ToiletTracker
|
|
|
24
24
|
private
|
|
25
25
|
|
|
26
26
|
def failure_for_invalid_format(line)
|
|
27
|
-
Core::Result.failure(Core::InvalidMessageFormat.new(line,
|
|
27
|
+
Core::Result.failure(Core::InvalidMessageFormat.new(line, "WhatsApp format"))
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def failure_for_invalid_sender(line)
|
|
31
|
-
Core::Result.failure(Core::InvalidMessageFormat.new(line,
|
|
31
|
+
Core::Result.failure(Core::InvalidMessageFormat.new(line, "Empty sender"))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def build_message_from_match(match)
|
|
@@ -46,16 +46,16 @@ module ToiletTracker
|
|
|
46
46
|
datetime_string = "#{day}/#{month}/#{full_year} #{time}"
|
|
47
47
|
|
|
48
48
|
# Parse in the default timezone
|
|
49
|
-
config.default_timezone_object.strptime(datetime_string,
|
|
49
|
+
config.default_timezone_object.strptime(datetime_string, "%d/%m/%Y %H:%M:%S")
|
|
50
50
|
rescue ArgumentError => e
|
|
51
51
|
raise Core::InvalidDateTime, "#{day}/#{month}/#{year} #{time}: #{e.message}"
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def clean_content(content)
|
|
55
|
-
return
|
|
55
|
+
return "" if content.nil?
|
|
56
56
|
|
|
57
57
|
# Remove the "This message was edited" suffix
|
|
58
|
-
content.gsub(config.edited_message_suffix,
|
|
58
|
+
content.gsub(config.edited_message_suffix, "").strip
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
end
|
|
@@ -9,7 +9,7 @@ module ToiletTracker
|
|
|
9
9
|
|
|
10
10
|
def initialize(timezone_service = TimezoneService.new)
|
|
11
11
|
@timezone_service = timezone_service
|
|
12
|
-
@user_shifts = Hash.new { |h, k| h[k] = [default_shift_entry] }
|
|
12
|
+
@user_shifts = Hash.new { |h, k| h[k] = [ default_shift_entry ] }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def set_shift(user, shift, effective_time = Time.current, timezone = nil)
|
|
@@ -65,7 +65,7 @@ module ToiletTracker
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def clear_history(user)
|
|
68
|
-
@user_shifts[user] = [default_shift_entry]
|
|
68
|
+
@user_shifts[user] = [ default_shift_entry ]
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
def clear_all_history
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "active_support/time"
|
|
4
4
|
|
|
5
5
|
module ToiletTracker
|
|
6
6
|
module Services
|
|
@@ -15,11 +15,11 @@ module ToiletTracker
|
|
|
15
15
|
from_zone = resolve_timezone(from_timezone)
|
|
16
16
|
to_zone = resolve_timezone(to_timezone)
|
|
17
17
|
|
|
18
|
-
# Calculate offset difference in hours
|
|
19
18
|
from_offset = from_zone.at(at_time).utc_offset
|
|
20
19
|
to_offset = to_zone.at(at_time).utc_offset
|
|
21
20
|
|
|
22
|
-
(to_offset - from_offset) / 3600
|
|
21
|
+
shift = (to_offset - from_offset) / 3600.0
|
|
22
|
+
(shift == shift.to_i) ? shift.to_i : shift
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def apply_shift(time, shift_hours)
|
|
@@ -82,7 +82,7 @@ module ToiletTracker
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def sample_times
|
|
85
|
-
[Time.current, Time.current + 6.months]
|
|
85
|
+
[ Time.current, Time.current + 6.months ]
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def add_zone_to_map(map, abbr, zone)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require "zip"
|
|
4
|
+
require "tempfile"
|
|
5
5
|
|
|
6
6
|
module ToiletTracker
|
|
7
7
|
module Utils
|
|
@@ -15,7 +15,7 @@ module ToiletTracker
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def self.zip_file?(file_path)
|
|
18
|
-
File.extname(file_path).downcase ==
|
|
18
|
+
File.extname(file_path).downcase == ".zip"
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def initialize(zip_path)
|
|
@@ -26,14 +26,14 @@ module ToiletTracker
|
|
|
26
26
|
def extract_chat_file
|
|
27
27
|
chat_entries = find_chat_entries
|
|
28
28
|
|
|
29
|
-
raise ChatFileNotFoundError,
|
|
29
|
+
raise ChatFileNotFoundError, "No _chat.txt file found in zip archive" if chat_entries.empty?
|
|
30
30
|
|
|
31
31
|
chat_entry = if chat_entries.size > 1
|
|
32
32
|
# If multiple _chat.txt files, prefer the one in root or with shortest path
|
|
33
|
-
chat_entries.min_by { |entry| entry.name.count(
|
|
34
|
-
|
|
33
|
+
chat_entries.min_by { |entry| entry.name.count("/") }
|
|
34
|
+
else
|
|
35
35
|
chat_entries.first
|
|
36
|
-
|
|
36
|
+
end
|
|
37
37
|
|
|
38
38
|
extract_to_tempfile(chat_entry)
|
|
39
39
|
end
|
|
@@ -64,7 +64,7 @@ module ToiletTracker
|
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def extract_to_tempfile(entry)
|
|
67
|
-
temp_file = Tempfile.new([
|
|
67
|
+
temp_file = Tempfile.new([ "chat_export", ".txt" ])
|
|
68
68
|
|
|
69
69
|
begin
|
|
70
70
|
Zip::File.open(@zip_path) do |_zip|
|
data/lib/toilet_tracker.rb
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "active_support/time"
|
|
4
4
|
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
14
|
-
require_relative
|
|
15
|
-
require_relative
|
|
16
|
-
require_relative
|
|
17
|
-
require_relative
|
|
5
|
+
require_relative "toilet_tracker/version"
|
|
6
|
+
require_relative "toilet_tracker/configuration"
|
|
7
|
+
require_relative "toilet_tracker/core/errors"
|
|
8
|
+
require_relative "toilet_tracker/core/message"
|
|
9
|
+
require_relative "toilet_tracker/core/result"
|
|
10
|
+
require_relative "toilet_tracker/core/parse_result"
|
|
11
|
+
require_relative "toilet_tracker/parsers/base_parser"
|
|
12
|
+
require_relative "toilet_tracker/parsers/whatsapp_parser"
|
|
13
|
+
require_relative "toilet_tracker/parsers/message_parser"
|
|
14
|
+
require_relative "toilet_tracker/services/timezone_service"
|
|
15
|
+
require_relative "toilet_tracker/services/shift_service"
|
|
16
|
+
require_relative "toilet_tracker/services/analysis_service"
|
|
17
|
+
require_relative "toilet_tracker/utils/zip_handler"
|
|
18
18
|
|
|
19
19
|
module ToiletTracker
|
|
20
20
|
class Error < StandardError; end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: toilet_tracker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- NetherSoul
|
|
@@ -13,16 +13,22 @@ dependencies:
|
|
|
13
13
|
name: activesupport
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: 8.0.2.1
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 8.2.0
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
28
|
version: 8.0.2.1
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 8.2.0
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: csv
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -41,16 +47,22 @@ dependencies:
|
|
|
41
47
|
name: rubyzip
|
|
42
48
|
requirement: !ruby/object:Gem::Requirement
|
|
43
49
|
requirements:
|
|
44
|
-
- - "
|
|
50
|
+
- - ">="
|
|
45
51
|
- !ruby/object:Gem::Version
|
|
46
52
|
version: 3.0.1
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: 3.3.0
|
|
47
56
|
type: :runtime
|
|
48
57
|
prerelease: false
|
|
49
58
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
59
|
requirements:
|
|
51
|
-
- - "
|
|
60
|
+
- - ">="
|
|
52
61
|
- !ruby/object:Gem::Version
|
|
53
62
|
version: 3.0.1
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: 3.3.0
|
|
54
66
|
- !ruby/object:Gem::Dependency
|
|
55
67
|
name: thor
|
|
56
68
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -106,7 +118,6 @@ files:
|
|
|
106
118
|
- ".overcommit.yml"
|
|
107
119
|
- ".rspec"
|
|
108
120
|
- ".rubocop.yml"
|
|
109
|
-
- ".standard.yml"
|
|
110
121
|
- CHANGELOG.md
|
|
111
122
|
- CODE_OF_CONDUCT.md
|
|
112
123
|
- LICENSE.txt
|
data/.standard.yml
DELETED