timet 1.4.4 → 1.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c617b0cdad056a121039feaacfb0fe04df4e3e21cea8d9261e0414a01c2e9014
4
- data.tar.gz: cac525344920d23e05ed0cab8798aa70e0153337328cac6bb170c6d96b9b509a
3
+ metadata.gz: fcf74496e5767f780af32f515ddaa47aac3a80056c62dd4e4ab680b4e380d9ea
4
+ data.tar.gz: 8013d89eb5d8f13c794bc3a8b65d976e6e5658d674183a82aa5c11df3c5e65e3
5
5
  SHA512:
6
- metadata.gz: e48e71078a2cfb0e7f92a67b008f323b487366ad41392e0027a1d229e29680018dbcf1b13012322735f94302e5c0e03576d544dd7b1897fd3973cf49ee7cc1d1
7
- data.tar.gz: 2a5a3fdabd064518b57aac6142c690dba881ce3d68ace78bd6770d5679fc11f24a71878a7422a80482aaaffb0e723bd8aa4c9a0ebf2de2fa5b8ee35a3082d98d
6
+ metadata.gz: b0b961c8397a2db270284d7a25530265c79861e812674f376a2457675cdf5f1b6614117ead4e7de39ae73118ffcf30998a17d15ee010b6ec08725f63803bc99a
7
+ data.tar.gz: aa487be7cff8c23f5c578ea624ad2cd8382d5216833275fde531266d87efd850b678a3df28e350d95561758ba5ea1efa255db9e7eb5fe60e1c09580e4c805ea6
data/CHANGELOG.md CHANGED
@@ -1,8 +1,43 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.5.0] - 2024-12-06
4
+
5
+ **Improvements:**
6
+
7
+ - Implemented soft delete for items, adding a `deleted` column to the `items` table.
8
+ - Improved database synchronization logic with better error handling and resource management.
9
+ - Refactored synchronization methods to improve readability and maintainability.
10
+ - Added `updated_at` and `created_at` columns to the `items` table for better tracking of item changes.
11
+ - Updated synchronization logic to handle deleted items during synchronization.
12
+ - Updated various gems to their latest versions, including `aws-sdk-core`, `aws-sdk-s3`, `json`, `regexp_parser`, `rubocop`, `rubocop-ast`, `sqlite3`, and `unicode-display_width`.
13
+ - Improved the handling of environment variables in the S3 configuration.
14
+ - Improved table formatting and display logic for better readability.
15
+ - Simplified pomodoro end time formatting for better performance.
16
+ - Added a check to skip items marked as deleted when generating CSV reports.
17
+ - Updated the README to reflect new features and improvements.
18
+ - Added YARD documentation for the `S3Supabase` class.
19
+
20
+ **Bug Fixes:**
21
+
22
+ - (No bug fixes listed in the provided commit messages)
23
+
24
+ ## [1.4.5] - 2024-11-18
25
+
26
+ **Improvements:**
27
+
28
+ - Added `base64` gem to the Gemfile to ensure compatibility with Ruby 3.4.0.
29
+ - Updated the `json` gem from version 2.8.1 to 2.8.2.
30
+ - Updated the `rubocop-ast` gem from version 1.35.0 to 1.36.1.
31
+ - Added the `icalendar` gem to the application.
32
+
33
+ **Bug Fixes:**
34
+
35
+ - Fixed the deprecation warning related to `base64` being removed from the Ruby standard library in Ruby 3.4.0.
36
+
3
37
  ## [1.4.4] - 2024-11-12
4
38
 
5
39
  **Improvements:**
40
+
6
41
  - Refactored tag distribution and time statistics methods:
7
42
  - Split `process_and_print_tags` into `print_summary` and `print_tags_info` for better modularity and readability.
8
43
  - Added Yardoc comments to document the new methods and updated existing comments for clarity.
@@ -13,12 +48,14 @@
13
48
  - Ensured that the iCalendar file generation logic is encapsulated within the `TimeReportHelper` module.
14
49
 
15
50
  **Tasks:**
51
+
16
52
  - Bumped version to 1.4.4.
17
53
  - Updated `Gemfile.lock`.
18
54
 
19
55
  ## [1.4.3] - 2024-11-06
20
56
 
21
57
  **Improvements:**
58
+
22
59
  - **Refactor export logic**: Introduced a new `ReportExporter` class to handle the export of reports to CSV and iCalendar formats, addressing the Feature Envy code smell and making the `ApplicationHelper` module more modular.
23
60
  - **Update gem dependencies**: Updated several gems to their latest versions, including `icalendar`, `sqlite3`, `json`, `parser`, `rubocop`, and `rubocop-ast`.
24
61
  - **Refactor `TimeReport` initialization**: Refactored `TimeReport` initialization to use an options hash instead of individual parameters, and added support for exporting tracking summaries to iCalendar format.
@@ -26,10 +63,10 @@
26
63
  - **Add `icalendar` gem**: Added the `icalendar` gem to support iCalendar functionality and updated the `timet` gem version to `1.4.3`.
27
64
 
28
65
  **Bug Fixes:**
66
+
29
67
  - Corrected platform names in the lockfile.
30
68
  - Updated the `TimeReport` spec to use the new options hash in the `TimeReport` initialization.
31
69
 
32
-
33
70
  ## [1.4.2] - 2024-11-01
34
71
 
35
72
  **Improvements:**
data/README.md CHANGED
@@ -7,9 +7,24 @@
7
7
 
8
8
  ![Timet](timet.webp)
9
9
 
10
- [Timet](https://rubygems.org/gems/timet) is a command-line tool designed to track your activities by recording the time spent on each task. This allows you to monitor your work hours and productivity directly from your terminal without needing a graphical interface. Essentially, it's a way to log your time spent on different projects or tasks using simple text commands.
11
10
 
12
- 🔑 **Key Features:**
11
+ ## Table of Contents
12
+
13
+ - [🔑 Key Features](#key-features)
14
+ - [✔️ Requirements](#requirements)
15
+ - [Examples](#examples)
16
+ - [💾 Installation](#installation)
17
+ - [⏳ Usage](#usage)
18
+ - [📋 Command Reference](#command-reference)
19
+ - [🗃️ Data](#️-data)
20
+ - [🔒 S3 Cloud Backup Configuration](#-s3-cloud-backup-configuration)
21
+ - [Contributing](#contributing)
22
+ - [License](#license)
23
+
24
+
25
+ [Timet](https://rubygems.org/gems/timet) is a command-line tool designed to track your activities by recording the time spent on each task. This tool allows you to monitor your work hours and productivity directly from your terminal, eliminating the need for a graphical interface. Essentially, it's a way to log your time spent on different projects or tasks using simple text commands.
26
+
27
+ <h2 id="key-features">🔑 Key Features:</h2>
13
28
 
14
29
  - **Local Data Storage:** Timet uses SQLite to store your time tracking data locally, ensuring privacy and security.
15
30
  - **Lightweight and Fast:** Its efficient design and local data storage make Timet a speedy and responsive tool.
@@ -23,12 +38,14 @@
23
38
  - **Tag Distribution Plot:** Illustrates the proportion of total tracked time allocated to each tag, showing the relative contribution of each tag to the overall time tracked.
24
39
  - **Detailed Statistics:** Displays detailed statistics for each tag, including total duration, average duration, and standard deviation.
25
40
  - **iCalendar Export:** Easily export your time tracking data to iCalendar format for integration with calendar applications.
41
+ - **S3 Cloud Backup:** Seamlessly backup and sync your time tracking data with S3-compatible storage services, providing an additional layer of data protection and accessibility.
26
42
 
27
- **Examples:**
43
+ ## Examples:
28
44
 
29
- ![Timet1 demo](timet1.gif)
45
+ ![Timet demo](timet1.gif)
30
46
 
31
- ## ✔️ Requirements
47
+ <a name="requirements"></a>
48
+ <h2 id="requirements">✔️ Requirements</h2>
32
49
 
33
50
  - Ruby version: >= 3.0.0
34
51
  - sqlite3: > 1.7
@@ -38,6 +55,7 @@ For older versions of Ruby and Sqlite:
38
55
  - [Ruby >= 2.7](https://github.com/frankvielma/timet/tree/ruby-2.7.0)
39
56
  - [Ruby >= 2.4](https://github.com/frankvielma/timet/tree/ruby-2.4.0)
40
57
 
58
+ <a name="installation"></a>
41
59
  ## 💾 Installation
42
60
 
43
61
  Install the gem by executing:
@@ -46,6 +64,7 @@ Install the gem by executing:
46
64
  gem install timet
47
65
  ```
48
66
 
67
+ <a name="usage"></a>
49
68
  ## ⏳ Usage
50
69
 
51
70
  ### Command Aliases
@@ -172,24 +191,26 @@ gem install timet
172
191
  +-------+------------+--------+----------+----------+----------+--------------------------+
173
192
  ```
174
193
 
194
+ <a name="command-reference"></a>
175
195
  ## 📋 Command Reference
176
196
 
177
197
  | Command | Description | Example Usage |
178
198
  | -------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------- |
179
- | `timet start [tag] --notes='' --pomodoro=[time]` | Start tracking time for a task labeled [tag] and notes (optional). | `timet start Task "My notes" 25` |
180
- | `timet stop` | Stop tracking time. | `timet stop` |
199
+ | `timet start [tag] --notes='' --pomodoro=[time]` | Start tracking time for a task labeled [tag] and notes (optional). | `timet start Task "My notes" 25` |
200
+ | `timet stop` | Stop tracking time. | `timet stop` |
181
201
  | `timet summary today (t)` | Display a report of tracked time for today. | `timet su t` or `timet su` |
182
202
  | `timet summary yesterday (y)` | Display a report of tracked time for yesterday. | `timet su y` |
183
203
  | `timet summary week (w)` | Display a report of tracked time for the week. | `timet su w` |
184
204
  | `timet summary month (m)` | Display a report of tracked time for the month. | `timet su m` |
185
- | `timet su t --csv=[filename]` | Display a report of tracked time for today and export to CSV file | `timet su t --csv=file.csv` |
186
- | `timet su w --ics=[filename]` | Display a report of tracked time for week and export to iCalendar file | `timet su w --ics=file.csv` |
205
+ | `timet su t --csv=[filename]` | Display a report of tracked time for today and export to CSV file | `timet su t --csv=file.csv` |
206
+ | `timet su w --ics=[filename]` | Display a report of tracked time for week and export to iCalendar file | `timet su w --ics=file.csv` |
187
207
  | `timet delete [id]` | Delete a task by its ID. | `timet d [id]` |
188
208
  | `timet cancel` | Cancel active time tracking. | `timet c` |
189
209
  | `timet edit [id]` | Update a task's notes, tag, start, or end fields. | `timet e [id]` |
190
210
  | `timet su [date]` | Display a report of tracked time for a specific date. | `timet su 2024-01-03` |
191
211
  | `timet su [start_date]..[end_date]` | Display a report of tracked time for a date range. | `timet su 2024-01-02..2024-01-03` |
192
212
  | `timet resume (r) [id]` | Resume tracking a task by ID or the last completed task. | `timet resume [id]` |
213
+ | `timet sync` | Sync local db with remote (S3) external db | `timet sync` |
193
214
 
194
215
  ### Date Range in Summary
195
216
 
@@ -209,9 +230,35 @@ The `timet summary` command now supports specifying a date range for generating
209
230
  timet su 2024-01-02..2024-01-03
210
231
  ```
211
232
 
212
- ## Data
233
+ <a name="data"></a>
234
+ ## 🗃️ Data
235
+
236
+ Timet's data is stored in `~/.timet`.
237
+
238
+ <a name="s3-cloud-backup-configuration"></a>
239
+ ## 🔒 S3 Cloud Backup Configuration
240
+
241
+ Timet supports backing up and syncing your time tracking data with S3-compatible storage services (such as Supabase S3). To configure S3 backup, follow these steps:
242
+
243
+ ### Environment Variables
244
+
245
+ Create a `.env` file in your project root (`~/.timet`) with the following variables:
246
+
247
+ ```bash
248
+ S3_ENDPOINT=your_s3_endpoint_url
249
+ S3_ACCESS_KEY=your_access_key
250
+ S3_SECRET_KEY=your_secret_key
251
+ S3_REGION=your_s3_region
252
+ ```
253
+
254
+ ### Security Considerations
255
+
256
+ - Keep your `.env` file private and never commit it to version control
257
+ - Use strong, unique access keys
258
+ - Regularly rotate your S3 access credentials
259
+ - Implement appropriate IAM policies to restrict bucket access
260
+
213
261
 
214
- Timet's data is stored in `~/.timet.db`.
215
262
 
216
263
  ## Development
217
264
 
@@ -1,12 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'version'
4
3
  require 'thor'
5
4
  require 'tty-prompt'
5
+ require 'icalendar'
6
+ require_relative 's3_supabase'
6
7
  require_relative 'validation_edit_helper'
7
8
  require_relative 'application_helper'
8
9
  require_relative 'time_helper'
9
-
10
+ require_relative 'version'
11
+ require_relative 'database_sync_helper'
12
+ require 'tempfile'
13
+ require 'digest'
10
14
  module Timet
11
15
  # Application class that defines CLI commands for time tracking:
12
16
  # - start: Start time tracking with optional notes
@@ -35,6 +39,8 @@ module Timet
35
39
 
36
40
  VALID_STATUSES_FOR_INSERTION = %i[no_items complete].freeze
37
41
 
42
+ BUCKET = 'timet'
43
+
38
44
  desc "start [tag] --notes='' --pomodoro=[min]",
39
45
  'Start time tracking for a task labeled with the provided [tag], notes and "pomodoro time"
40
46
  in minutes (optional).
@@ -72,7 +78,7 @@ module Timet
72
78
 
73
79
  return puts 'A task is currently being tracked.' unless VALID_STATUSES_FOR_INSERTION.include?(@db.item_status)
74
80
 
75
- @db.insert_item(start_time, tag, notes, pomodoro)
81
+ @db.insert_item(start_time, tag, notes, pomodoro, start_time, start_time)
76
82
  play_sound_and_notify(pomodoro * 60, tag) if pomodoro.positive?
77
83
  summary
78
84
  end
@@ -271,5 +277,11 @@ module Timet
271
277
  def version
272
278
  puts Timet::VERSION
273
279
  end
280
+
281
+ desc 'sync', 'Sync local db with supabase external db'
282
+ def sync
283
+ puts 'Syncing database with remote storage...'
284
+ DatabaseSyncHelper.sync(@db, BUCKET)
285
+ end
274
286
  end
275
287
  end
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'sqlite3'
4
5
  module Timet
5
6
  # Provides database access for managing time tracking data.
6
7
  class Database
7
8
  # The default path to the SQLite database file.
8
- DEFAULT_DATABASE_PATH = File.join(Dir.home, '.timet.db')
9
+ DEFAULT_DATABASE_PATH = File.join(Dir.home, '.timet', 'timet.db')
9
10
 
10
11
  # Initializes a new instance of the Database class.
11
12
  #
@@ -23,11 +24,17 @@ module Timet
23
24
  # @note The method creates a new SQLite3 database connection and initializes the necessary tables if they
24
25
  # do not already exist.
25
26
  def initialize(database_path = DEFAULT_DATABASE_PATH)
27
+ move_old_database_file(database_path)
28
+
26
29
  @db = SQLite3::Database.new(database_path)
27
30
  create_table
28
31
 
29
32
  add_column('items', 'notes', 'TEXT')
30
33
  add_column('items', 'pomodoro', 'INTEGER')
34
+ add_column('items', 'updated_at', 'INTEGER')
35
+ add_column('items', 'created_at', 'INTEGER')
36
+ add_column('items', 'deleted', 'INTEGER')
37
+ update_time_columns
31
38
  end
32
39
 
33
40
  # Creates the items table if it doesn't already exist.
@@ -87,8 +94,9 @@ module Timet
87
94
  # insert_item(1633072800, 'work', 'Completed task X')
88
95
  #
89
96
  # @note The method executes SQL to insert a new row into the 'items' table.
90
- def insert_item(start, tag, notes, pomodoro = nil)
91
- execute_sql('INSERT INTO items (start, tag, notes, pomodoro) VALUES (?, ?, ?, ?)', [start, tag, notes, pomodoro])
97
+ def insert_item(start, tag, notes, pomodoro = nil, updated_at = nil, created_at = nil)
98
+ execute_sql('INSERT INTO items (start, tag, notes, pomodoro, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?)',
99
+ [start, tag, notes, pomodoro, updated_at, created_at])
92
100
  end
93
101
 
94
102
  # Updates an existing item in the items table.
@@ -107,7 +115,7 @@ module Timet
107
115
  def update_item(id, field, value)
108
116
  return if %w[start end].include?(field) && value.nil?
109
117
 
110
- execute_sql("UPDATE items SET #{field}='#{value}' WHERE id = #{id}")
118
+ execute_sql("UPDATE items SET #{field}='#{value}', updated_at=#{Time.now.utc.to_i} WHERE id = #{id}")
111
119
  end
112
120
 
113
121
  # Deletes an item from the items table.
@@ -122,7 +130,8 @@ module Timet
122
130
  #
123
131
  # @note The method executes SQL to delete the item with the given ID from the 'items' table.
124
132
  def delete_item(id)
125
- execute_sql("DELETE FROM items WHERE id = #{id}")
133
+ current_time = Time.now.to_i
134
+ execute_sql('UPDATE items SET deleted = 1, updated_at = ? WHERE id = ?', [current_time, id])
126
135
  end
127
136
 
128
137
  # Fetches the ID of the last inserted item.
@@ -134,8 +143,8 @@ module Timet
134
143
  #
135
144
  # @note The method executes SQL to fetch the ID of the last inserted item.
136
145
  def fetch_last_id
137
- result = execute_sql('SELECT id FROM items ORDER BY id DESC LIMIT 1').first
138
- result ? result[0] : nil
146
+ result = execute_sql('SELECT id FROM items WHERE deleted IS NULL OR deleted = 0 ORDER BY id DESC LIMIT 1')
147
+ result.empty? ? nil : result[0][0]
139
148
  end
140
149
 
141
150
  # Fetches the last item from the items table.
@@ -147,23 +156,8 @@ module Timet
147
156
  #
148
157
  # @note The method executes SQL to fetch the last item from the 'items' table.
149
158
  def last_item
150
- execute_sql('SELECT * FROM items ORDER BY id DESC LIMIT 1').first
151
- end
152
-
153
- # Determines the status of the last item in the items table.
154
- #
155
- # @return [Symbol] The status of the last item. Possible values are :no_items, :in_progress, or :complete.
156
- #
157
- # @example Determine the status of the last item
158
- # item_status
159
- #
160
- # @note The method executes SQL to fetch the last item and determines its status using the `StatusHelper` module.
161
- #
162
- # @param id [Integer, nil] The ID of the item to check. If nil, the last item in the table is used.
163
- #
164
- def item_status(id = nil)
165
- id = fetch_last_id if id.nil?
166
- determine_status(find_item(id))
159
+ result = execute_sql('SELECT * FROM items WHERE deleted IS NULL OR deleted = 0 ORDER BY id DESC LIMIT 1')
160
+ result.empty? ? nil : result[0]
167
161
  end
168
162
 
169
163
  # Finds an item in the items table by its ID.
@@ -177,7 +171,8 @@ module Timet
177
171
  #
178
172
  # @note The method executes SQL to find the item with the given ID in the 'items' table.
179
173
  def find_item(id)
180
- execute_sql("SELECT * from items where id=#{id}").first
174
+ result = execute_sql('SELECT * FROM items WHERE id = ? AND (deleted IS NULL OR deleted = 0)', [id])
175
+ result.empty? ? nil : result[0]
181
176
  end
182
177
 
183
178
  # Fetches all items from the items table that have a start time greater than or equal to today.
@@ -190,7 +185,25 @@ module Timet
190
185
  # @note The method executes SQL to fetch all items from the 'items' table that have a start time greater than
191
186
  # or equal to today.
192
187
  def all_items
193
- execute_sql("SELECT * FROM items where start >= '#{Date.today.to_time.to_i}' ORDER BY id DESC")
188
+ today = Time.now.to_i - (Time.now.to_i % 86_400)
189
+ execute_sql('SELECT * FROM items WHERE start >= ? AND (deleted IS NULL OR deleted = 0) ORDER BY start DESC',
190
+ [today])
191
+ end
192
+
193
+ # Determines the status of the last item in the items table.
194
+ #
195
+ # @return [Symbol] The status of the last item. Possible values are :no_items, :in_progress, or :complete.
196
+ #
197
+ # @example Determine the status of the last item
198
+ # item_status
199
+ #
200
+ # @note The method executes SQL to fetch the last item and determines its status using the `StatusHelper` module.
201
+ #
202
+ # @param id [Integer, nil] The ID of the item to check. If nil, the last item in the table is used.
203
+ #
204
+ def item_status(id = nil)
205
+ id = fetch_last_id if id.nil?
206
+ determine_status(find_item(id))
194
207
  end
195
208
 
196
209
  # Executes a SQL query and returns the result.
@@ -269,5 +282,42 @@ module Timet
269
282
 
270
283
  :complete
271
284
  end
285
+
286
+ private
287
+
288
+ # Moves the old database file to the new location if it exists.
289
+ #
290
+ # @param database_path [String] The path to the new SQLite database file.
291
+ def move_old_database_file(database_path)
292
+ old_file = File.join(Dir.home, '.timet.db')
293
+ return unless File.exist?(old_file)
294
+
295
+ FileUtils.mkdir_p(File.dirname(database_path)) unless File.directory?(File.dirname(database_path))
296
+ FileUtils.mv(old_file, database_path)
297
+ end
298
+
299
+ # Updates the `updated_at` and `created_at` columns for items where either of these columns is null.
300
+ #
301
+ # This method queries the database for items where the `updated_at` or `created_at` columns are null.
302
+ # For each item found, it sets both the `updated_at` and `created_at` columns to the value of the `end_time` column.
303
+ #
304
+ # @note This method directly executes SQL queries on the database. Ensure that the `execute_sql` method is properly
305
+ # defined and handles SQL injection risks.
306
+ #
307
+ # @return [void] This method does not return a value.
308
+ #
309
+ # @example
310
+ # update_time_columns
311
+ #
312
+ # @raise [StandardError] If there is an issue executing the SQL queries, an error may be raised.
313
+ #
314
+ def update_time_columns
315
+ result = execute_sql('SELECT * FROM items where updated_at is null or created_at is null')
316
+ result.each do |item|
317
+ id = item[0]
318
+ end_time = item[2]
319
+ execute_sql("UPDATE items SET updated_at = #{end_time}, created_at = #{end_time} WHERE id = #{id}")
320
+ end
321
+ end
272
322
  end
273
323
  end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tempfile'
4
+ require 'digest'
5
+
6
+ module Timet
7
+ # Helper module for database synchronization operations
8
+ # Provides methods for comparing and syncing local and remote databases
9
+ module DatabaseSyncHelper
10
+ # Fields used in item operations
11
+ ITEM_FIELDS = %w[start end tag notes pomodoro updated_at created_at deleted].freeze
12
+
13
+ # Main entry point for database synchronization
14
+ #
15
+ # @param local_db [SQLite3::Database] The local database connection
16
+ # @param bucket [String] The S3 bucket name
17
+ # @return [void]
18
+ # @note This method initiates the database synchronization process by checking for the presence of a remote database
19
+ def self.sync(local_db, bucket)
20
+ remote_storage = S3Supabase.new
21
+ remote_storage.create_bucket(bucket)
22
+
23
+ objects = remote_storage.list_objects(bucket)
24
+ if objects&.any? { |obj| obj[:key] == 'timet.db' }
25
+ process_remote_database(local_db, remote_storage, bucket, Timet::Database::DEFAULT_DATABASE_PATH)
26
+ else
27
+ puts 'No remote database found, uploading local database'
28
+ remote_storage.upload_file(bucket, Timet::Database::DEFAULT_DATABASE_PATH, 'timet.db')
29
+ end
30
+ end
31
+
32
+ # Processes the remote database by comparing it with the local database and syncing changes
33
+ #
34
+ # @param local_db [SQLite3::Database] The local database connection
35
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
36
+ # @param bucket [String] The S3 bucket name
37
+ # @param local_db_path [String] Path to the local database file
38
+ # @return [void]
39
+ # @note This method orchestrates the entire sync process by downloading the remote database,
40
+ # comparing it with the local database, and handling any differences found
41
+ def self.process_remote_database(local_db, remote_storage, bucket, local_db_path)
42
+ with_temp_file do |temp_file|
43
+ remote_storage.download_file(bucket, 'timet.db', temp_file.path)
44
+
45
+ if databases_are_in_sync?(temp_file.path, local_db_path)
46
+ puts 'Local database is up to date'
47
+ else
48
+ handle_database_differences(local_db, remote_storage, bucket, local_db_path, temp_file.path)
49
+ end
50
+ end
51
+ end
52
+
53
+ # Creates a temporary file and ensures it is properly cleaned up after use
54
+ #
55
+ # @yield [Tempfile] The temporary file object to use
56
+ # @return [void]
57
+ # @note This method ensures proper resource cleanup by using ensure block
58
+ def self.with_temp_file
59
+ temp_file = Tempfile.new('remote_db')
60
+ yield temp_file
61
+ ensure
62
+ temp_file.close
63
+ temp_file.unlink
64
+ end
65
+
66
+ # Compares two database files to check if they are identical
67
+ #
68
+ # @param remote_path [String] Path to the remote database file
69
+ # @param local_path [String] Path to the local database file
70
+ # @return [Boolean] true if databases are identical, false otherwise
71
+ # @note Uses MD5 hashing to compare file contents
72
+ def self.databases_are_in_sync?(remote_path, local_path)
73
+ remote_md5 = Digest::MD5.file(remote_path).hexdigest
74
+ local_md5 = Digest::MD5.file(local_path).hexdigest
75
+ remote_md5 == local_md5
76
+ end
77
+
78
+ # Handles the synchronization process when differences are detected between databases
79
+ #
80
+ # @param local_db [SQLite3::Database] The local database connection
81
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
82
+ # @param bucket [String] The S3 bucket name
83
+ # @param local_db_path [String] Path to the local database file
84
+ # @param remote_path [String] Path to the downloaded remote database file
85
+ # @return [void]
86
+ # @note This method attempts to sync the databases and handles any errors that occur during the process
87
+ def self.handle_database_differences(*args)
88
+ local_db, remote_storage, bucket, local_db_path, remote_path = args
89
+ puts 'Differences detected between local and remote databases'
90
+ begin
91
+ sync_with_remote_database(local_db, remote_path, remote_storage, bucket, local_db_path)
92
+ rescue SQLite3::Exception => e
93
+ handle_sync_error(e, remote_storage, bucket, local_db_path)
94
+ end
95
+ end
96
+
97
+ # Performs the actual database synchronization by setting up connections and syncing data
98
+ #
99
+ # @param local_db [SQLite3::Database] The local database connection
100
+ # @param remote_path [String] Path to the remote database file
101
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
102
+ # @param bucket [String] The S3 bucket name
103
+ # @param local_db_path [String] Path to the local database file
104
+ # @return [void]
105
+ # @note Configures both databases to return results as hashes for consistent data handling
106
+ def self.sync_with_remote_database(*args)
107
+ local_db, remote_path, remote_storage, bucket, local_db_path = args
108
+ db_remote = open_remote_database(remote_path)
109
+ db_remote.results_as_hash = true
110
+ local_db.instance_variable_get(:@db).results_as_hash = true
111
+ sync_databases(local_db, db_remote, remote_storage, bucket, local_db_path)
112
+ end
113
+
114
+ # Opens and validates a connection to the remote database
115
+ #
116
+ # @param remote_path [String] Path to the remote database file
117
+ # @return [SQLite3::Database] The initialized database connection
118
+ # @raise [RuntimeError] If the database connection cannot be established
119
+ # @note Validates that the database connection is properly initialized
120
+ def self.open_remote_database(remote_path)
121
+ db_remote = SQLite3::Database.new(remote_path)
122
+ raise 'Failed to initialize remote database' unless db_remote
123
+
124
+ db_remote
125
+ end
126
+
127
+ # Handles errors that occur during database synchronization
128
+ #
129
+ # @param error [SQLite3::Exception] The error that occurred during sync
130
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
131
+ # @param bucket [String] The S3 bucket name
132
+ # @param local_db_path [String] Path to the local database file
133
+ # @return [void]
134
+ # @note When sync fails, this method falls back to uploading the local database
135
+ def self.handle_sync_error(error, remote_storage, bucket, local_db_path)
136
+ puts "Error opening remote database: #{error.message}"
137
+ puts 'Uploading local database to replace corrupted remote database'
138
+ remote_storage.upload_file(bucket, local_db_path, 'timet.db')
139
+ end
140
+
141
+ # Converts database items to a hash indexed by ID
142
+ #
143
+ # @param items [Array<Hash>] Array of database items
144
+ # @return [Hash] Items indexed by ID
145
+ def self.items_to_hash(items)
146
+ items.to_h { |item| [item['id'], item] }
147
+ end
148
+
149
+ # Determines if remote item should take precedence
150
+ #
151
+ # @param remote_item [Hash] Remote database item
152
+ # @param remote_time [Integer] Remote item timestamp
153
+ # @param local_time [Integer] Local item timestamp
154
+ # @return [Boolean] true if remote item should take precedence
155
+ def self.remote_wins?(remote_item, remote_time, local_time)
156
+ remote_time > local_time && (remote_item['deleted'].to_i == 1 || remote_time > local_time)
157
+ end
158
+
159
+ # Formats item status message
160
+ #
161
+ # @param id [Integer] Item ID
162
+ # @param item [Hash] Database item
163
+ # @param source [String] Source of the item ('Remote' or 'Local')
164
+ # @return [String] Formatted status message
165
+ def self.format_status_message(id, item, source)
166
+ deleted = item['deleted'].to_i == 1 ? ' and deleted' : ''
167
+ "#{source} item #{id} is newer#{deleted} - #{source == 'Remote' ? 'updating local' : 'will be uploaded'}"
168
+ end
169
+
170
+ # Processes an item that exists in both databases
171
+ #
172
+ # @param id [Integer] Item ID
173
+ # @param local_item [Hash] Local database item
174
+ # @param remote_item [Hash] Remote database item
175
+ # @param local_db [SQLite3::Database] Local database connection
176
+ # @return [Symbol] :local_update if local was updated, :remote_update if remote needs update
177
+ def self.process_existing_item(*args)
178
+ id, local_item, remote_item, local_db = args
179
+ local_time = local_item['updated_at'].to_i
180
+ remote_time = remote_item['updated_at'].to_i
181
+
182
+ if remote_wins?(remote_item, remote_time, local_time)
183
+ puts format_status_message(id, remote_item, 'Remote')
184
+ update_item_from_hash(local_db, remote_item)
185
+ :local_update
186
+ elsif local_time > remote_time
187
+ puts format_status_message(id, local_item, 'Local')
188
+ :remote_update
189
+ end
190
+ end
191
+
192
+ # Processes items from both databases and syncs them
193
+ #
194
+ # @param local_db [SQLite3::Database] The local database connection
195
+ # @param remote_db [SQLite3::Database] The remote database connection
196
+ # @return [void]
197
+ def self.process_database_items(local_db, remote_db)
198
+ remote_items = remote_db.execute('SELECT * FROM items ORDER BY updated_at DESC')
199
+ local_items = local_db.execute_sql('SELECT * FROM items ORDER BY updated_at DESC')
200
+
201
+ remote_by_id = items_to_hash(remote_items)
202
+ local_by_id = items_to_hash(local_items)
203
+ all_ids = (remote_by_id.keys + local_by_id.keys).uniq
204
+
205
+ all_ids.each do |id|
206
+ remote_item = remote_by_id[id]
207
+ local_item = local_by_id[id]
208
+
209
+ if remote_item && local_item
210
+ process_existing_item(id, local_item, remote_item, local_db)
211
+ elsif remote_item
212
+ puts "Adding remote item #{id} to local"
213
+ insert_item_from_hash(local_db, remote_item)
214
+ else # local_item exists
215
+ puts "Local item #{id} will be uploaded"
216
+ end
217
+ end
218
+ end
219
+
220
+ # Synchronizes the local and remote databases by comparing and merging their items
221
+ #
222
+ # @param local_db [SQLite3::Database] The local database connection
223
+ # @param remote_db [SQLite3::Database] The remote database connection
224
+ # @param remote_storage [S3Supabase] The remote storage client for cloud operations
225
+ # @param bucket [String] The S3 bucket name
226
+ # @param local_db_path [String] Path to the local database file
227
+ # @return [void]
228
+ # @note This method orchestrates the entire database synchronization process
229
+ def self.sync_databases(*args)
230
+ local_db, remote_db, remote_storage, bucket, local_db_path = args
231
+ process_database_items(local_db, remote_db)
232
+ remote_storage.upload_file(bucket, local_db_path, 'timet.db')
233
+ puts 'Database sync completed'
234
+ end
235
+
236
+ # Gets the values array for database operations
237
+ #
238
+ # @param item [Hash] Hash containing item data
239
+ # @param include_id [Boolean] Whether to include ID at start (insert) or end (update)
240
+ # @return [Array] Array of values for database operation
241
+ def self.get_item_values(item, include_id_at_start: false)
242
+ values = ITEM_FIELDS.map { |field| item[field] }
243
+ include_id_at_start ? [item['id'], *values] : [*values, item['id']]
244
+ end
245
+
246
+ # Updates an existing item in the database with values from a hash
247
+ #
248
+ # @param db [SQLite3::Database] The database connection
249
+ # @param item [Hash] Hash containing item data
250
+ # @return [void]
251
+ def self.update_item_from_hash(db, item)
252
+ fields = "#{ITEM_FIELDS.join(' = ?, ')} = ?"
253
+ db.execute_sql(
254
+ "UPDATE items SET #{fields} WHERE id = ?",
255
+ get_item_values(item)
256
+ )
257
+ end
258
+
259
+ # Inserts a new item into the database from a hash
260
+ #
261
+ # @param db [SQLite3::Database] The database connection
262
+ # @param item [Hash] Hash containing item data
263
+ # @return [void]
264
+ def self.insert_item_from_hash(db, item)
265
+ fields = ['id', *ITEM_FIELDS].join(', ')
266
+ placeholders = Array.new(ITEM_FIELDS.length + 1, '?').join(', ')
267
+ db.execute_sql(
268
+ "INSERT INTO items (#{fields}) VALUES (#{placeholders})",
269
+ get_item_values(item, include_id_at_start: true)
270
+ )
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-s3'
4
+ require 'logger'
5
+ require 'dotenv'
6
+ require 'fileutils'
7
+
8
+ # The module includes several components:
9
+ # - S3 integration for data backup and sync
10
+ #
11
+ module Timet
12
+ # Required environment variables for S3 configuration
13
+ REQUIRED_ENV_VARS = %w[S3_ENDPOINT S3_ACCESS_KEY S3_SECRET_KEY].freeze
14
+
15
+ # Ensures that the environment file exists and contains the required variables.
16
+ # If the file doesn't exist, it creates it. If required variables are missing,
17
+ # it adds them with empty values.
18
+ #
19
+ # @param env_file_path [String] The path to the environment file
20
+ # @return [void]
21
+ # @example
22
+ # Timet.ensure_env_file_exists('/path/to/.env')
23
+ def self.ensure_env_file_exists(env_file_path)
24
+ dir_path = File.dirname(env_file_path)
25
+ FileUtils.mkdir_p(dir_path)
26
+
27
+ # Create file if it doesn't exist or is empty
28
+ File.write(env_file_path, '', mode: 'a')
29
+
30
+ # Load and check environment variables
31
+ Dotenv.load(env_file_path)
32
+ missing_vars = REQUIRED_ENV_VARS.reject { |var| ENV.fetch(var, nil) }
33
+
34
+ # Append missing variables with empty values
35
+ return if missing_vars.empty?
36
+
37
+ File.write(env_file_path, missing_vars.map { |var| "#{var}=''" }.join("\n") + "\n", mode: 'a')
38
+ end
39
+
40
+ # S3Supabase is a class that provides methods to interact with an S3-compatible
41
+ # storage service. It encapsulates common operations such as creating a bucket,
42
+ # listing objects, uploading and downloading files, deleting objects, and
43
+ # deleting a bucket.
44
+ #
45
+ # This class requires the following environment variables to be set:
46
+ # - S3_ENDPOINT: The endpoint URL for the S3-compatible storage service.
47
+ # - S3_ACCESS_KEY: The access key ID for authentication.
48
+ # - S3_SECRET_KEY: The secret access key for authentication.
49
+ # - S3_REGION: The region for the S3-compatible storage service (default: 'us-west-1').
50
+ #
51
+ # @example Basic usage
52
+ # s3_supabase = S3Supabase.new
53
+ # s3_supabase.create_bucket('my-bucket')
54
+ # s3_supabase.upload_file('my-bucket', '/path/to/local/file.txt', 'file.txt')
55
+ #
56
+ # @example Advanced operations
57
+ # s3_supabase.list_objects('my-bucket')
58
+ # s3_supabase.download_file('my-bucket', 'file.txt', '/path/to/download/file.txt')
59
+ # s3_supabase.delete_object('my-bucket', 'file.txt')
60
+ # s3_supabase.delete_bucket('my-bucket')
61
+ class S3Supabase
62
+ ENV_FILE_PATH = File.join(Dir.home, '.timet', '.env')
63
+ Timet.ensure_env_file_exists(ENV_FILE_PATH)
64
+ Dotenv.load(ENV_FILE_PATH)
65
+
66
+ S3_ENDPOINT = ENV.fetch('S3_ENDPOINT', nil)
67
+ S3_ACCESS_KEY = ENV.fetch('S3_ACCESS_KEY', nil)
68
+ S3_SECRET_KEY = ENV.fetch('S3_SECRET_KEY', nil)
69
+ S3_REGION = ENV.fetch('S3_REGION', 'us-west-1')
70
+ LOG_FILE_PATH = File.join(Dir.home, '.timet', 's3_supabase.log')
71
+
72
+ # Initializes a new instance of the S3Supabase class.
73
+ # Sets up the AWS S3 client with the configured credentials and endpoint.
74
+ #
75
+ # @raise [CustomError] If required environment variables are missing
76
+ def initialize
77
+ validate_env_vars
78
+ @logger = Logger.new(LOG_FILE_PATH)
79
+ @logger.level = Logger::INFO
80
+ @s3_client = Aws::S3::Client.new(
81
+ region: S3_REGION,
82
+ access_key_id: S3_ACCESS_KEY,
83
+ secret_access_key: S3_SECRET_KEY,
84
+ endpoint: S3_ENDPOINT,
85
+ force_path_style: true
86
+ )
87
+ end
88
+
89
+ # Creates a new bucket in the S3-compatible storage service.
90
+ #
91
+ # @param bucket_name [String] The name of the bucket to create
92
+ # @return [Boolean] true if bucket was created successfully, false otherwise
93
+ # @example
94
+ # create_bucket('my-new-bucket')
95
+ def create_bucket(bucket_name)
96
+ begin
97
+ @s3_client.create_bucket(bucket: bucket_name)
98
+ @logger.info "Bucket '#{bucket_name}' created successfully!"
99
+ return true
100
+ rescue Aws::S3::Errors::BucketAlreadyExists
101
+ @logger.error "Error: The bucket '#{bucket_name}' already exists."
102
+ rescue Aws::S3::Errors::BucketAlreadyOwnedByYou
103
+ @logger.error "Error: The bucket '#{bucket_name}' is already owned by you."
104
+ rescue Aws::S3::Errors::ServiceError => e
105
+ @logger.error "Error creating bucket: #{e.message}"
106
+ end
107
+ false
108
+ end
109
+
110
+ # Lists all objects in the specified bucket.
111
+ #
112
+ # @param bucket_name [String] The name of the bucket to list objects from
113
+ # @return [Array<Hash>, false, nil] Array of object hashes if objects found, false if bucket is empty, nil if error occurs
114
+ # @raise [Aws::S3::Errors::ServiceError] if there's an error accessing the S3 service
115
+ # @example
116
+ # list_objects('my-bucket') #=> [{key: 'example.txt', last_modified: '2023-01-01', ...}, ...]
117
+ # list_objects('empty-bucket') #=> false
118
+ # list_objects('invalid-bucket') #=> nil
119
+ def list_objects(bucket_name)
120
+ response = @s3_client.list_objects_v2(bucket: bucket_name)
121
+ if response.contents.empty?
122
+ @logger.info "No objects found in '#{bucket_name}'."
123
+ false
124
+ else
125
+ @logger.info "Objects in '#{bucket_name}':"
126
+ response.contents.each { |object| @logger.info "- #{object.key} (Last modified: #{object.last_modified})" }
127
+ response.contents.map(&:to_h)
128
+ end
129
+ rescue Aws::S3::Errors::ServiceError => e
130
+ @logger.error "Error listing objects: #{e.message}"
131
+ nil
132
+ end
133
+
134
+ # Uploads a file to the specified bucket.
135
+ #
136
+ # @param bucket_name [String] The name of the bucket to upload to
137
+ # @param file_path [String] The local path of the file to upload
138
+ # @param object_key [String] The key (name) to give the object in the bucket
139
+ # @return [void]
140
+ # @example
141
+ # upload_file('my-bucket', '/path/to/local/file.txt', 'remote-file.txt')
142
+ def upload_file(bucket_name, file_path, object_key)
143
+ @s3_client.put_object(
144
+ bucket: bucket_name,
145
+ key: object_key,
146
+ body: File.open(file_path, 'rb')
147
+ )
148
+ @logger.info "File '#{object_key}' uploaded successfully."
149
+ rescue Aws::S3::Errors::ServiceError => e
150
+ @logger.error "Error uploading file: #{e.message}"
151
+ end
152
+
153
+ # Downloads a file from the specified bucket.
154
+ #
155
+ # @param bucket_name [String] The name of the bucket to download from
156
+ # @param object_key [String] The key of the object to download
157
+ # @param download_path [String] The local path where the file should be saved
158
+ # @return [void]
159
+ # @example
160
+ # download_file('my-bucket', 'remote-file.txt', '/path/to/local/file.txt')
161
+ def download_file(bucket_name, object_key, download_path)
162
+ response = @s3_client.get_object(bucket: bucket_name, key: object_key)
163
+ File.binwrite(download_path, response.body.read)
164
+ @logger.info "File '#{object_key}' downloaded successfully."
165
+ rescue Aws::S3::Errors::ServiceError => e
166
+ @logger.error "Error downloading file: #{e.message}"
167
+ end
168
+
169
+ # Deletes an object from the specified bucket.
170
+ #
171
+ # @param bucket_name [String] The name of the bucket containing the object
172
+ # @param object_key [String] The key of the object to delete
173
+ # @return [void]
174
+ # @example
175
+ # delete_object('my-bucket', 'file-to-delete.txt')
176
+ def delete_object(bucket_name, object_key)
177
+ @s3_client.delete_object(bucket: bucket_name, key: object_key)
178
+ @logger.info "Object '#{object_key}' deleted successfully."
179
+ rescue Aws::S3::Errors::ServiceError => e
180
+ @logger.error "Error deleting object: #{e.message}"
181
+ end
182
+
183
+ # Deletes a bucket and all its contents.
184
+ # First deletes all objects in the bucket, then deletes the bucket itself.
185
+ #
186
+ # @param bucket_name [String] The name of the bucket to delete
187
+ # @return [void]
188
+ # @example
189
+ # delete_bucket('bucket-to-delete')
190
+ def delete_bucket(bucket_name)
191
+ list_objects(bucket_name)
192
+ @s3_client.list_objects_v2(bucket: bucket_name).contents.each do |object|
193
+ delete_object(bucket_name, object.key)
194
+ end
195
+ @s3_client.delete_bucket(bucket: bucket_name)
196
+ @logger.info "Bucket '#{bucket_name}' deleted successfully."
197
+ rescue Aws::S3::Errors::ServiceError => e
198
+ @logger.error "Error deleting bucket: #{e.message}"
199
+ end
200
+
201
+ private
202
+
203
+ # Validates that all required environment variables are present and non-empty.
204
+ #
205
+ # @raise [CustomError] If any required environment variables are missing
206
+ # @return [void]
207
+ def validate_env_vars
208
+ missing_vars = []
209
+ missing_vars << 'S3_ENDPOINT' if S3_ENDPOINT.empty?
210
+ missing_vars << 'S3_ACCESS_KEY' if S3_ACCESS_KEY.empty?
211
+ missing_vars << 'S3_SECRET_KEY' if S3_SECRET_KEY.empty?
212
+
213
+ return if missing_vars.empty?
214
+
215
+ error_message = "Missing required environment variables (.env): #{missing_vars.join(', ')}"
216
+ raise CustomError, error_message
217
+ end
218
+
219
+ # Custom error class that suppresses the backtrace for cleaner error messages.
220
+ #
221
+ # @example
222
+ # raise CustomError, "Missing required environment variables"
223
+ class CustomError < StandardError
224
+ def backtrace
225
+ nil
226
+ end
227
+ end
228
+ end
229
+ end
data/lib/timet/table.rb CHANGED
@@ -44,7 +44,7 @@ module Timet
44
44
  header = <<~TABLE
45
45
  #{title}
46
46
  #{separator}
47
- \033[32m| Id | Date | Tag | Start | End | Duration | Notes |\033[0m
47
+ \033[32m| Id | Date | Tag | Start | End | Duration | Notes\033[0m
48
48
  #{separator}
49
49
  TABLE
50
50
  puts header
@@ -59,7 +59,7 @@ module Timet
59
59
  #
60
60
  # @note The method returns a string representing the separator line for the table.
61
61
  def separator
62
- '+-------+------------+--------+----------+----------+----------+--------------------+'
62
+ '+-------+------------+--------+----------+----------+----------+'
63
63
  end
64
64
 
65
65
  # Processes time entries and generates a time block structure.
@@ -165,7 +165,7 @@ module Timet
165
165
  mark = format_mark(id)
166
166
 
167
167
  "| #{id.to_s.rjust(6)}| #{start_date} | #{tag.ljust(6)} | #{start_time.split[1]} | " \
168
- "#{end_time.rjust(8)} | #{@db.seconds_to_hms(duration).rjust(8)} | #{format_notes(notes)} #{mark}"
168
+ "#{end_time.rjust(8)} | #{@db.seconds_to_hms(duration).rjust(8)} #{mark} #{format_notes(notes)}"
169
169
  end
170
170
 
171
171
  # Formats the end time of the time entry.
@@ -190,9 +190,9 @@ module Timet
190
190
  pomodoro = @db.find_item(id)[5] || 0
191
191
 
192
192
  if pomodoro.positive? && end_time == '-'
193
- delta = (@db.find_item(id)[5] - (duration / 60.0)).round(1)
194
- timet = "\e]8;;Session ends\a#{delta} min\e]8;;\a".green
195
- end_time = " #{timet}".blink
193
+ delta = @db.seconds_to_hms((@db.find_item(id)[5] * 60) - duration)
194
+ timet = "\e]8;;Session ends\a#{delta}\e]8;;\a".green
195
+ end_time = timet.to_s.blink
196
196
  end
197
197
 
198
198
  end_time
@@ -216,7 +216,7 @@ module Timet
216
216
  def format_mark(id)
217
217
  pomodoro = @db.find_item(id)[5] || 0
218
218
  mark = '|'
219
- mark = "#{'├'.white} #{'P'.blue.blink}" if pomodoro.positive?
219
+ mark = "#{'├'.white}#{'P'.blue.blink}" if pomodoro.positive?
220
220
  mark
221
221
  end
222
222
 
@@ -230,7 +230,7 @@ module Timet
230
230
  #
231
231
  # @note The method truncates the notes to a maximum of 20 characters and pads them to a fixed width.
232
232
  def format_notes(notes)
233
- spaces = 17
233
+ spaces = 80
234
234
  return ' ' * spaces unless notes
235
235
 
236
236
  max_length = spaces - 3
@@ -250,7 +250,7 @@ module Timet
250
250
  total = @items.map do |item|
251
251
  TimeHelper.calculate_duration(item[1], item[2])
252
252
  end.sum
253
- puts "|#{' ' * 43}#{'Total:'.blue} | #{@db.seconds_to_hms(total).rjust(8).blue} |#{' ' * 20}|"
253
+ puts "|#{' ' * 43}#{'Total:'.blue} | #{@db.seconds_to_hms(total).rjust(8).blue} |"
254
254
  puts separator
255
255
  display_pomodoro_label
256
256
  end
@@ -168,7 +168,12 @@ module Timet
168
168
  def filter_by_date_range(start_date, end_date = nil, tag = nil)
169
169
  start_time = TimeHelper.date_to_timestamp(start_date)
170
170
  end_time = TimeHelper.calculate_end_time(start_date, end_date)
171
- query = "start >= #{start_time} and start < #{end_time} and tag like '%#{tag}%'"
171
+ query = [
172
+ "start >= #{start_time}",
173
+ "start < #{end_time}",
174
+ "tag like '%#{tag}%'",
175
+ '(deleted IS NULL OR deleted = 0)'
176
+ ].join(' and ')
172
177
  @db.execute_sql(
173
178
  "select * from items where #{query} ORDER BY id DESC"
174
179
  )
data/lib/timet/version.rb CHANGED
@@ -6,6 +6,6 @@ module Timet
6
6
  # @return [String] The version number in the format 'major.minor.patch'.
7
7
  #
8
8
  # @example Get the version of the Timet application
9
- # Timet::VERSION # => '1.4.4'
10
- VERSION = '1.4.4'
9
+ # Timet::VERSION # => '1.5.0'
10
+ VERSION = '1.5.0'
11
11
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: timet
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.4
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frank Vielma
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-12 00:00:00.000000000 Z
11
+ date: 2024-12-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -58,6 +58,62 @@ dependencies:
58
58
  - - ">="
59
59
  - !ruby/object:Gem::Version
60
60
  version: '1.7'
61
+ - !ruby/object:Gem::Dependency
62
+ name: icalendar
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2'
68
+ type: :runtime
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '2'
75
+ - !ruby/object:Gem::Dependency
76
+ name: descriptive_statistics
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2'
89
+ - !ruby/object:Gem::Dependency
90
+ name: dotenv
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3'
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3'
103
+ - !ruby/object:Gem::Dependency
104
+ name: aws-sdk-s3
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.171'
110
+ type: :runtime
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '1.171'
61
117
  description: Timet is a command-line time tracker that keeps track of your activities.
62
118
  email:
63
119
  - frankvielma@gmail.com
@@ -82,6 +138,8 @@ files:
82
138
  - lib/timet/application_helper.rb
83
139
  - lib/timet/color_codes.rb
84
140
  - lib/timet/database.rb
141
+ - lib/timet/database_sync_helper.rb
142
+ - lib/timet/s3_supabase.rb
85
143
  - lib/timet/table.rb
86
144
  - lib/timet/tag_distribution.rb
87
145
  - lib/timet/time_block_chart.rb