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 +4 -4
- data/CHANGELOG.md +38 -1
- data/README.md +58 -11
- data/lib/timet/application.rb +15 -3
- data/lib/timet/database.rb +76 -26
- data/lib/timet/database_sync_helper.rb +273 -0
- data/lib/timet/s3_supabase.rb +229 -0
- data/lib/timet/table.rb +9 -9
- data/lib/timet/time_report.rb +6 -1
- data/lib/timet/version.rb +2 -2
- metadata +60 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fcf74496e5767f780af32f515ddaa47aac3a80056c62dd4e4ab680b4e380d9ea
|
4
|
+
data.tar.gz: 8013d89eb5d8f13c794bc3a8b65d976e6e5658d674183a82aa5c11df3c5e65e3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|

|
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
|
-
|
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
|
-
|
43
|
+
## Examples:
|
28
44
|
|
29
|
-

|
30
46
|
|
31
|
-
|
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).
|
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
|
-
|
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
|
|
data/lib/timet/application.rb
CHANGED
@@ -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
|
data/lib/timet/database.rb
CHANGED
@@ -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 (?, ?, ?, ?)',
|
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
|
-
|
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')
|
138
|
-
result ? result[0]
|
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')
|
151
|
-
|
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(
|
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
|
-
|
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
|
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)}
|
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] -
|
194
|
-
timet = "\e]8;;Session ends\a#{delta}
|
195
|
-
end_time =
|
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}
|
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 =
|
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}
|
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
|
data/lib/timet/time_report.rb
CHANGED
@@ -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 =
|
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
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
|
+
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
|
+
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
|