qtimetrap 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +25 -0
- data/README.md +92 -0
- data/Rakefile +105 -0
- data/app/assets/icons/qtimetrap-icon-128.png +0 -0
- data/app/assets/icons/qtimetrap-icon-256.png +0 -0
- data/app/assets/icons/qtimetrap-icon-512.png +0 -0
- data/app/assets/icons/qtimetrap-icon.svg +123 -0
- data/app/components/entries/branch_hierarchy_helpers.rb +41 -0
- data/app/components/entries/leaf_archive_helpers.rb +23 -0
- data/app/components/entries/leaf_note_helpers.rb +118 -0
- data/app/components/entries/leaf_task_helpers.rb +81 -0
- data/app/components/entries/leaf_time_helpers.rb +105 -0
- data/app/components/entries/list_component.rb +118 -0
- data/app/components/entries/list_host_helpers.rb +27 -0
- data/app/components/entries/list_state_helpers.rb +59 -0
- data/app/components/entries/node_presentation_helpers.rb +29 -0
- data/app/components/entries/render_helpers.rb +25 -0
- data/app/components/entries/tree_helpers.rb +133 -0
- data/app/components/entries/tree_toolbar_helpers.rb +98 -0
- data/app/components/project_sidebar/archive_toggle_helpers.rb +22 -0
- data/app/components/project_sidebar/component.rb +125 -0
- data/app/components/project_sidebar/logo_helpers.rb +53 -0
- data/app/components/project_sidebar/project_button_helpers.rb +72 -0
- data/app/components/project_sidebar/project_selection_helpers.rb +94 -0
- data/app/components/project_sidebar/task_helpers.rb +94 -0
- data/app/components/project_sidebar/task_selection_helpers.rb +72 -0
- data/app/components/qt_ui_helpers.rb +27 -0
- data/app/components/tracker_controls/component.rb +80 -0
- data/app/components/tracker_controls/layout_builder.rb +100 -0
- data/app/components/tracker_controls/layout_helpers.rb +88 -0
- data/app/models/null_settings_store.rb +12 -0
- data/app/models/time_entry.rb +59 -0
- data/app/services/archived_entries_store.rb +71 -0
- data/app/services/formatters.rb +29 -0
- data/app/services/settings_store.rb +83 -0
- data/app/services/timetrap_gateway.rb +89 -0
- data/app/services/timetrap_gateway_logger.rb +54 -0
- data/app/services/timetrap_gateway_query_helpers.rb +102 -0
- data/app/services/timetrap_gateway_start_helpers.rb +28 -0
- data/app/services/timetrap_gateway_update_note_helpers.rb +48 -0
- data/app/services/timetrap_gateway_update_task_helpers.rb +47 -0
- data/app/services/timetrap_gateway_update_time_helpers.rb +50 -0
- data/app/styles/theme.rb +61 -0
- data/app/styles/themes/dark/application.qss +8 -0
- data/app/styles/themes/dark/entries_list.qss +235 -0
- data/app/styles/themes/dark/project_sidebar.qss +99 -0
- data/app/styles/themes/dark/snippets/app_background.qss +2 -0
- data/app/styles/themes/dark/snippets/button_ghost.qss +5 -0
- data/app/styles/themes/dark/snippets/button_start.qss +5 -0
- data/app/styles/themes/dark/snippets/button_stop.qss +5 -0
- data/app/styles/themes/dark/snippets/entries_host.qss +2 -0
- data/app/styles/themes/dark/snippets/entries_scroll.qss +16 -0
- data/app/styles/themes/dark/snippets/entry_row_day.qss +6 -0
- data/app/styles/themes/dark/snippets/entry_row_detail.qss +5 -0
- data/app/styles/themes/dark/snippets/entry_row_project.qss +6 -0
- data/app/styles/themes/dark/snippets/project_button.qss +5 -0
- data/app/styles/themes/dark/snippets/project_button_active.qss +5 -0
- data/app/styles/themes/dark/snippets/project_sidebar_heading.qss +2 -0
- data/app/styles/themes/dark/snippets/project_sidebar_logo.qss +3 -0
- data/app/styles/themes/dark/snippets/project_sidebar_panel.qss +2 -0
- data/app/styles/themes/dark/snippets/tracker_clock.qss +2 -0
- data/app/styles/themes/dark/snippets/tracker_input.qss +5 -0
- data/app/styles/themes/dark/snippets/tracker_project_label.qss +5 -0
- data/app/styles/themes/dark/snippets/tracker_row.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_summary.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_timer.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_title.qss +3 -0
- data/app/styles/themes/dark/snippets/tracker_topbar.qss +2 -0
- data/app/styles/themes/dark/tracker_controls.qss +84 -0
- data/app/styles/themes/light/application.qss +8 -0
- data/app/styles/themes/light/entries_list.qss +235 -0
- data/app/styles/themes/light/project_sidebar.qss +99 -0
- data/app/styles/themes/light/snippets/app_background.qss +2 -0
- data/app/styles/themes/light/snippets/button_ghost.qss +5 -0
- data/app/styles/themes/light/snippets/button_start.qss +5 -0
- data/app/styles/themes/light/snippets/button_stop.qss +5 -0
- data/app/styles/themes/light/snippets/entries_host.qss +2 -0
- data/app/styles/themes/light/snippets/entries_scroll.qss +16 -0
- data/app/styles/themes/light/snippets/entry_row_day.qss +6 -0
- data/app/styles/themes/light/snippets/entry_row_detail.qss +5 -0
- data/app/styles/themes/light/snippets/entry_row_project.qss +6 -0
- data/app/styles/themes/light/snippets/project_button.qss +5 -0
- data/app/styles/themes/light/snippets/project_button_active.qss +5 -0
- data/app/styles/themes/light/snippets/project_sidebar_heading.qss +2 -0
- data/app/styles/themes/light/snippets/project_sidebar_logo.qss +3 -0
- data/app/styles/themes/light/snippets/project_sidebar_panel.qss +2 -0
- data/app/styles/themes/light/snippets/tracker_clock.qss +2 -0
- data/app/styles/themes/light/snippets/tracker_input.qss +5 -0
- data/app/styles/themes/light/snippets/tracker_project_label.qss +5 -0
- data/app/styles/themes/light/snippets/tracker_row.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_summary.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_timer.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_title.qss +3 -0
- data/app/styles/themes/light/snippets/tracker_topbar.qss +2 -0
- data/app/styles/themes/light/tracker_controls.qss +84 -0
- data/app/view_models/entry_nodes_builder.rb +109 -0
- data/app/view_models/main_view_model.rb +135 -0
- data/app/view_models/main_view_model_archive_mode_helpers.rb +102 -0
- data/app/view_models/main_view_model_entry_note_helpers.rb +30 -0
- data/app/view_models/main_view_model_entry_task_helpers.rb +39 -0
- data/app/view_models/main_view_model_entry_time_helpers.rb +45 -0
- data/app/view_models/main_view_model_sheet_helpers.rb +74 -0
- data/app/view_models/main_view_model_task_filter_helpers.rb +58 -0
- data/app/view_models/main_view_model_time_range_filter_helpers.rb +23 -0
- data/app/views/main_window.rb +126 -0
- data/app/views/main_window_layout_builder.rb +114 -0
- data/app/views/main_window_runtime.rb +123 -0
- data/app/views/main_window_runtime_archive_helpers.rb +26 -0
- data/app/views/main_window_runtime_entry_task_helpers.rb +21 -0
- data/app/views/main_window_runtime_entry_time_helpers.rb +17 -0
- data/app/views/main_window_runtime_key_helpers.rb +55 -0
- data/app/views/main_window_runtime_render_helpers.rb +50 -0
- data/app/views/main_window_splitter_toggle_bootstrap_helpers.rb +34 -0
- data/app/views/main_window_splitter_toggle_helpers.rb +103 -0
- data/app/views/main_window_splitter_toggle_hover_helpers.rb +71 -0
- data/app/views/main_window_splitter_toggle_layout_helpers.rb +24 -0
- data/app/views/main_window_ui_helpers.rb +45 -0
- data/app/views/window_icon_loader.rb +37 -0
- data/bin/qtimetrap +7 -0
- data/config/application.rb +87 -0
- data/config/environments/development.rb +5 -0
- data/config/environments/production.rb +5 -0
- data/config/initializers/theme.rb +5 -0
- data/config/initializers/timetrap.rb +5 -0
- data/lib/qtimetrap/configuration.rb +15 -0
- data/lib/qtimetrap/container.rb +57 -0
- data/lib/qtimetrap/version.rb +5 -0
- data/lib/qtimetrap.rb +25 -0
- metadata +207 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QTimetrap
|
|
4
|
+
module TrackerControls
|
|
5
|
+
# Builds tracker controls widget tree and wires all button callbacks.
|
|
6
|
+
class LayoutBuilder
|
|
7
|
+
include QtUiHelpers
|
|
8
|
+
include LayoutHelpers
|
|
9
|
+
|
|
10
|
+
def initialize(parent:, callbacks:)
|
|
11
|
+
@parent = parent
|
|
12
|
+
@callbacks = callbacks
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build
|
|
16
|
+
@widget = QWidget.new(parent)
|
|
17
|
+
root = build_root_layout
|
|
18
|
+
root.add_widget(build_topbar)
|
|
19
|
+
start_button, stop_button = build_tracker_row(root)
|
|
20
|
+
refresh_button = build_actions_row(root)
|
|
21
|
+
connect_actions(start_button, stop_button, refresh_button)
|
|
22
|
+
ui_payload
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :parent, :widget, :callbacks
|
|
28
|
+
|
|
29
|
+
def build_topbar
|
|
30
|
+
topbar = QWidget.new(widget)
|
|
31
|
+
topbar.set_object_name('topbar')
|
|
32
|
+
layout = QHBoxLayout.new(topbar)
|
|
33
|
+
configure_topbar_layout(layout, topbar)
|
|
34
|
+
topbar
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_tracker_row(root_layout)
|
|
38
|
+
row = QWidget.new(widget)
|
|
39
|
+
row.set_object_name('tracker_row')
|
|
40
|
+
layout = QHBoxLayout.new(row)
|
|
41
|
+
layout.set_contents_margins(14, 12, 14, 12)
|
|
42
|
+
layout.set_spacing(8)
|
|
43
|
+
start_button, stop_button = add_tracker_row_widgets(layout, row)
|
|
44
|
+
root_layout.add_widget(row)
|
|
45
|
+
[start_button, stop_button]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_actions_row(root_layout)
|
|
49
|
+
row = QWidget.new(widget)
|
|
50
|
+
layout = QHBoxLayout.new(row)
|
|
51
|
+
layout.set_contents_margins(0, 0, 0, 0)
|
|
52
|
+
layout.set_spacing(8)
|
|
53
|
+
refresh_button = add_actions_row_widgets(layout, row)
|
|
54
|
+
root_layout.add_widget(row)
|
|
55
|
+
refresh_button
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_task_input(parent_widget)
|
|
59
|
+
QLineEdit.new(parent_widget).tap do |input|
|
|
60
|
+
input.set_object_name('task_input')
|
|
61
|
+
input.set_placeholder_text('What are you working on?')
|
|
62
|
+
input.set_focus_policy(Qt::ClickFocus)
|
|
63
|
+
input.text = ''
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_project_input(parent_widget)
|
|
68
|
+
QLineEdit.new(parent_widget).tap do |input|
|
|
69
|
+
input.set_object_name('project_input')
|
|
70
|
+
input.set_placeholder_text('your project')
|
|
71
|
+
input.set_focus_policy(Qt::ClickFocus)
|
|
72
|
+
input.text = ''
|
|
73
|
+
input.set_fixed_width(190)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def connect_actions(start_button, stop_button, refresh_button)
|
|
78
|
+
connect_start_stop(start_button, stop_button)
|
|
79
|
+
connect_theme_refresh(refresh_button)
|
|
80
|
+
connect_project_input
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connect_start_stop(start_button, stop_button)
|
|
84
|
+
start_button.connect('clicked') do |_|
|
|
85
|
+
callbacks.fetch(:on_start).call(@task_input.text.to_s, @project_input.text.to_s)
|
|
86
|
+
end
|
|
87
|
+
stop_button.connect('clicked') { |_| callbacks.fetch(:on_stop).call }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def connect_theme_refresh(refresh_button)
|
|
91
|
+
@theme_button.connect('clicked') { |_| callbacks.fetch(:on_switch_theme).call }
|
|
92
|
+
refresh_button.connect('clicked') { |_| callbacks.fetch(:on_refresh).call }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def connect_project_input
|
|
96
|
+
@project_input.connect('textChanged(QString)') { |text| callbacks.fetch(:on_project_change).call(text.to_s) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QTimetrap
|
|
4
|
+
module TrackerControls
|
|
5
|
+
# Extracted helper methods for tracker controls layout assembly.
|
|
6
|
+
module LayoutHelpers
|
|
7
|
+
TITLE_SLOGANS = [
|
|
8
|
+
'Track time, ship value',
|
|
9
|
+
'Small steps, big output',
|
|
10
|
+
'Focus. Build. Finish.',
|
|
11
|
+
'Consistency beats intensity',
|
|
12
|
+
"Today's minutes, tomorrow's results",
|
|
13
|
+
'Make progress visible',
|
|
14
|
+
'Do the next right task'
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def build_root_layout
|
|
20
|
+
QVBoxLayout.new(widget).tap do |layout|
|
|
21
|
+
layout.set_contents_margins(0, 8, 0, 0)
|
|
22
|
+
layout.set_spacing(14)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def configure_topbar_layout(layout, topbar)
|
|
27
|
+
layout.set_contents_margins(16, 8, 16, 8)
|
|
28
|
+
layout.set_spacing(8)
|
|
29
|
+
layout.add_widget(build_label(topbar, 'title_label', random_title_slogan))
|
|
30
|
+
layout.add_stretch(1)
|
|
31
|
+
@clock_label = build_label(topbar, 'clock_label', nil, width: 220)
|
|
32
|
+
@clock_label.set_alignment(Qt::AlignCenter)
|
|
33
|
+
layout.add_widget(@clock_label)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_tracker_row_widgets(layout, row)
|
|
37
|
+
@task_input = build_task_input(row)
|
|
38
|
+
@project_input = build_project_input(row)
|
|
39
|
+
@timer_label = build_label(row, 'timer_label', '00:00:00', width: 120)
|
|
40
|
+
@timer_label.set_alignment(Qt::AlignCenter)
|
|
41
|
+
@start_button = build_button(row, 'start_button', 'START', 64, 48)
|
|
42
|
+
@stop_button = build_button(row, 'stop_button', 'STOP', 64, 48)
|
|
43
|
+
@stop_button.hide
|
|
44
|
+
[@task_input, @project_input, @timer_label, @start_button, @stop_button].each { |item| layout.add_widget(item) }
|
|
45
|
+
[@start_button, @stop_button]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def add_actions_row_widgets(layout, row)
|
|
49
|
+
@summary_label = build_label(row, 'summary_label', nil, height: 42)
|
|
50
|
+
@summary_label.set_alignment(Qt::AlignCenter)
|
|
51
|
+
@theme_button = build_button(row, 'theme_button', 'THEME', 112, 34)
|
|
52
|
+
refresh_button = build_button(row, 'refresh_button', 'REFRESH', 110, 34)
|
|
53
|
+
layout.add_widget(@summary_label)
|
|
54
|
+
layout.add_stretch(1)
|
|
55
|
+
layout.add_widget(@theme_button)
|
|
56
|
+
layout.add_widget(refresh_button)
|
|
57
|
+
refresh_button
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def ui_payload
|
|
61
|
+
ui_core_payload.merge(ui_controls_payload)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def ui_core_payload
|
|
65
|
+
{
|
|
66
|
+
widget: @widget,
|
|
67
|
+
task_input: @task_input,
|
|
68
|
+
clock_label: @clock_label,
|
|
69
|
+
timer_label: @timer_label
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def ui_controls_payload
|
|
74
|
+
{
|
|
75
|
+
summary_label: @summary_label,
|
|
76
|
+
project_input: @project_input,
|
|
77
|
+
theme_button: @theme_button,
|
|
78
|
+
start_button: @start_button,
|
|
79
|
+
stop_button: @stop_button
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def random_title_slogan
|
|
84
|
+
TITLE_SLOGANS.sample
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QTimetrap
|
|
4
|
+
module Models
|
|
5
|
+
# No-op settings persistence implementation used as a safe fallback.
|
|
6
|
+
class NullSettingsStore
|
|
7
|
+
def write_theme_name(_theme_name); end
|
|
8
|
+
def read_window_geometry; end
|
|
9
|
+
def write_window_geometry(left:, top:, width:, height:); end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module QTimetrap
|
|
6
|
+
module Models
|
|
7
|
+
# Immutable time tracking entry model used across view and services.
|
|
8
|
+
class TimeEntry
|
|
9
|
+
attr_reader :id, :note, :sheet, :start_time, :end_time
|
|
10
|
+
|
|
11
|
+
def initialize(id:, note:, sheet:, start_time:, end_time:)
|
|
12
|
+
@id = id
|
|
13
|
+
@note = note.to_s
|
|
14
|
+
@sheet = sheet.to_s
|
|
15
|
+
@start_time = start_time
|
|
16
|
+
@end_time = end_time
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def running?
|
|
20
|
+
end_time.nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def duration_seconds(now: Time.now)
|
|
24
|
+
return 0 unless start_time
|
|
25
|
+
|
|
26
|
+
finish = end_time || now
|
|
27
|
+
[finish.to_i - start_time.to_i, 0].max
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def project
|
|
31
|
+
split_sheet.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def task
|
|
35
|
+
split_sheet.last
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def day
|
|
39
|
+
(start_time || Time.now).to_date
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def split_sheet
|
|
45
|
+
raw = sheet.strip
|
|
46
|
+
return ['(default)', '(default task)'] if raw.empty?
|
|
47
|
+
|
|
48
|
+
parts = raw.split('|', 2)
|
|
49
|
+
return [raw, '(default task)'] unless parts.size == 2
|
|
50
|
+
|
|
51
|
+
project = parts.first.strip
|
|
52
|
+
task = parts.last.strip
|
|
53
|
+
project = '(default)' if project.empty?
|
|
54
|
+
task = '(default task)' if task.empty?
|
|
55
|
+
[project, task]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module QTimetrap
|
|
7
|
+
module Services
|
|
8
|
+
# Persists archived Timetrap entry ids in a local app-owned store.
|
|
9
|
+
class ArchivedEntriesStore
|
|
10
|
+
def initialize(path: default_path)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def archived_ids
|
|
15
|
+
read_ids
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def archived?(entry_id)
|
|
19
|
+
read_ids.include?(Integer(entry_id))
|
|
20
|
+
rescue ArgumentError, TypeError
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def archive(entry_id)
|
|
25
|
+
id = Integer(entry_id)
|
|
26
|
+
ids = read_ids
|
|
27
|
+
return if ids.include?(id)
|
|
28
|
+
|
|
29
|
+
ids << id
|
|
30
|
+
write_ids(ids)
|
|
31
|
+
rescue ArgumentError, TypeError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def unarchive(entry_id)
|
|
36
|
+
id = Integer(entry_id)
|
|
37
|
+
ids = read_ids
|
|
38
|
+
return unless ids.delete(id)
|
|
39
|
+
|
|
40
|
+
write_ids(ids)
|
|
41
|
+
rescue ArgumentError, TypeError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
attr_reader :path
|
|
48
|
+
|
|
49
|
+
def read_ids
|
|
50
|
+
return [] unless File.exist?(path)
|
|
51
|
+
|
|
52
|
+
payload = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
53
|
+
return [] unless payload.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
Array(payload['archived_entry_ids']).filter_map { |value| Integer(value) }.uniq.sort
|
|
56
|
+
rescue Psych::SyntaxError, ArgumentError, TypeError
|
|
57
|
+
[]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def write_ids(ids)
|
|
61
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
62
|
+
File.write(path, { 'archived_entry_ids' => ids.uniq.sort }.to_yaml)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def default_path
|
|
66
|
+
data_home = ENV.fetch('XDG_DATA_HOME', File.join(Dir.home, '.local', 'share'))
|
|
67
|
+
File.join(data_home, 'qtimetrap', 'archived_entries.yml')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QTimetrap
|
|
4
|
+
module Services
|
|
5
|
+
# Stateless value formatters used by view models and UI rendering.
|
|
6
|
+
module Formatters
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def seconds_to_hms(seconds)
|
|
10
|
+
seconds = [seconds.to_i, 0].max
|
|
11
|
+
hours = seconds / 3600
|
|
12
|
+
minutes = (seconds % 3600) / 60
|
|
13
|
+
secs = seconds % 60
|
|
14
|
+
format('%<h>02d:%<m>02d:%<s>02d', h: hours, m: minutes, s: secs)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def time_range(entry)
|
|
18
|
+
start_label, finish_label = time_bounds(entry)
|
|
19
|
+
"#{start_label} - #{finish_label}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def time_bounds(entry)
|
|
23
|
+
start_label = entry.start_time ? entry.start_time.strftime('%H:%M') : '--:--'
|
|
24
|
+
finish_label = entry.end_time ? entry.end_time.strftime('%H:%M') : 'running'
|
|
25
|
+
[start_label, finish_label]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module QTimetrap
|
|
7
|
+
module Services
|
|
8
|
+
# Persists lightweight UI settings in a YAML file under user config.
|
|
9
|
+
class SettingsStore
|
|
10
|
+
def initialize(path: default_path)
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def read_theme_name
|
|
15
|
+
data['theme']
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write_theme_name(theme_name)
|
|
19
|
+
value = theme_name.to_s.strip
|
|
20
|
+
return if value.empty?
|
|
21
|
+
|
|
22
|
+
payload = data.merge('theme' => value)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
24
|
+
File.write(path, payload.to_yaml)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read_window_geometry
|
|
28
|
+
value = data['window']
|
|
29
|
+
return nil unless value.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
geometry = stringify_keys(value)
|
|
32
|
+
parse_window_geometry(geometry)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write_window_geometry(left:, top:, width:, height:)
|
|
36
|
+
payload = data.merge(
|
|
37
|
+
'window' => {
|
|
38
|
+
'left' => Integer(left),
|
|
39
|
+
'top' => Integer(top),
|
|
40
|
+
'width' => Integer(width),
|
|
41
|
+
'height' => Integer(height)
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
45
|
+
File.write(path, payload.to_yaml)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :path
|
|
51
|
+
|
|
52
|
+
def data
|
|
53
|
+
return {} unless File.exist?(path)
|
|
54
|
+
|
|
55
|
+
loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
56
|
+
loaded.is_a?(Hash) ? stringify_keys(loaded) : {}
|
|
57
|
+
rescue Psych::SyntaxError
|
|
58
|
+
{}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stringify_keys(hash)
|
|
62
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_window_geometry(geometry)
|
|
66
|
+
left = Integer(geometry['left'] || geometry['x'])
|
|
67
|
+
top = Integer(geometry['top'] || geometry['y'])
|
|
68
|
+
width = Integer(geometry['width'])
|
|
69
|
+
height = Integer(geometry['height'])
|
|
70
|
+
return nil unless width.positive? && height.positive?
|
|
71
|
+
|
|
72
|
+
{ left: left, top: top, width: width, height: height }
|
|
73
|
+
rescue ArgumentError, TypeError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def default_path
|
|
78
|
+
config_home = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
|
|
79
|
+
File.join(config_home, 'qtimetrap', 'config.yml')
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
begin
|
|
7
|
+
require 'timetrap'
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# CLI fallback is supported.
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module QTimetrap
|
|
13
|
+
module Services
|
|
14
|
+
# Integrates with Timetrap via Ruby API or CLI fallback.
|
|
15
|
+
class TimetrapGateway
|
|
16
|
+
include TimetrapGatewayStartHelpers
|
|
17
|
+
include TimetrapGatewayUpdateTaskHelpers
|
|
18
|
+
include TimetrapGatewayUpdateTimeHelpers
|
|
19
|
+
include TimetrapGatewayUpdateNoteHelpers
|
|
20
|
+
include TimetrapGatewayQueryHelpers
|
|
21
|
+
|
|
22
|
+
def initialize(bin: ENV.fetch('TIMETRAP_BIN', 't'), logger: TimetrapGatewayLogger.new)
|
|
23
|
+
@bin = bin
|
|
24
|
+
@logger = logger
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entries
|
|
28
|
+
if api_available?
|
|
29
|
+
logger.log_api(operation: 'entries', input: {}, output: 'requested')
|
|
30
|
+
result = entries_from_api
|
|
31
|
+
logger.log_api(operation: 'entries', input: {}, output: { count: result.size })
|
|
32
|
+
return result
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
entries_from_cli
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def active_started_at
|
|
39
|
+
if api_available?
|
|
40
|
+
logger.log_api(operation: 'active_started_at', input: {}, output: 'requested')
|
|
41
|
+
result = active_started_at_from_api
|
|
42
|
+
logger.log_api(operation: 'active_started_at', input: {}, output: result&.iso8601)
|
|
43
|
+
return result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
_active, started_at = active_from_cli
|
|
47
|
+
started_at
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def start(sheet, checkin_note = nil)
|
|
51
|
+
normalized_sheet, normalized_checkin_note = normalize_start_inputs(sheet, checkin_note)
|
|
52
|
+
return if normalized_sheet.empty?
|
|
53
|
+
|
|
54
|
+
if api_available?
|
|
55
|
+
logger.log_api(
|
|
56
|
+
operation: 'start',
|
|
57
|
+
input: { sheet: normalized_sheet, checkin_note: normalized_checkin_note },
|
|
58
|
+
output: 'requested'
|
|
59
|
+
)
|
|
60
|
+
result = start_via_api(normalized_sheet, normalized_checkin_note)
|
|
61
|
+
logger.log_api(operation: 'start', input: {}, output: result.to_s)
|
|
62
|
+
return result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
start_via_cli(normalized_sheet, normalized_checkin_note)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stop
|
|
69
|
+
if api_available?
|
|
70
|
+
logger.log_api(operation: 'stop', input: {}, output: 'requested')
|
|
71
|
+
active = Timetrap::Timer.active_entry
|
|
72
|
+
Timetrap::Timer.stop(active) if active
|
|
73
|
+
logger.log_api(operation: 'stop', input: {}, output: active ? 'stopped' : 'noop')
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
run('out')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def api_available?
|
|
83
|
+
defined?(Timetrap::Entry) && defined?(Timetrap::Timer)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
attr_reader :bin, :logger
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module QTimetrap
|
|
8
|
+
module Services
|
|
9
|
+
# Writes Timetrap gateway input/output events to local log file.
|
|
10
|
+
class TimetrapGatewayLogger
|
|
11
|
+
DEFAULT_LOG_PATH = File.join(Dir.home, '.local', 'log', 'qtimetrap', 'timetrap_gateway.log')
|
|
12
|
+
|
|
13
|
+
def initialize(path: DEFAULT_LOG_PATH)
|
|
14
|
+
@path = path
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def log_cli(bin:, args:, success:, output:)
|
|
18
|
+
write(
|
|
19
|
+
kind: 'cli',
|
|
20
|
+
bin: bin.to_s,
|
|
21
|
+
input: Array(args).map(&:to_s),
|
|
22
|
+
success: success ? true : false,
|
|
23
|
+
output: output.to_s
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def log_api(operation:, input:, output:, success: true)
|
|
28
|
+
write(
|
|
29
|
+
kind: 'api',
|
|
30
|
+
operation: operation.to_s,
|
|
31
|
+
input: input,
|
|
32
|
+
success: success ? true : false,
|
|
33
|
+
output: output
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :path
|
|
40
|
+
|
|
41
|
+
def write(payload)
|
|
42
|
+
ensure_log_dir!
|
|
43
|
+
line = JSON.generate(payload.merge(at: Time.now.utc.iso8601))
|
|
44
|
+
File.open(path, 'a') { |file| file.puts(line) }
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ensure_log_dir!
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QTimetrap
|
|
4
|
+
module Services
|
|
5
|
+
# Query/run helpers for TimetrapGateway CLI and API data reads.
|
|
6
|
+
module TimetrapGatewayQueryHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def entries_from_api
|
|
10
|
+
Timetrap::Entry.order(:start).all.map do |entry|
|
|
11
|
+
Models::TimeEntry.new(
|
|
12
|
+
id: entry.id,
|
|
13
|
+
note: entry.note,
|
|
14
|
+
sheet: entry.sheet,
|
|
15
|
+
start_time: entry[:start],
|
|
16
|
+
end_time: entry[:end]
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def entries_from_cli
|
|
22
|
+
ok, output = run('display', '--format', 'json')
|
|
23
|
+
return [] unless ok
|
|
24
|
+
|
|
25
|
+
output = normalize_text(output)
|
|
26
|
+
rows = parse_rows(output)
|
|
27
|
+
return [] unless rows
|
|
28
|
+
|
|
29
|
+
rows.map { |row| build_entry(row) }
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def active_started_at_from_api
|
|
35
|
+
active = Timetrap::Timer.active_entry
|
|
36
|
+
active ? active[:start] : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def active_from_cli
|
|
40
|
+
ok, output = run('now')
|
|
41
|
+
return [nil, nil] unless ok
|
|
42
|
+
|
|
43
|
+
output = normalize_text(output)
|
|
44
|
+
match = output.match(/(\d{4}-\d{2}-\d{2} [0-9:]+ [+-]\d{4})/)
|
|
45
|
+
[true, (match ? parse_time(match[1]) : nil)]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run(*args)
|
|
49
|
+
normalized_args = normalize_command_args(args)
|
|
50
|
+
output, status = with_unbundled_env { Open3.capture2e(bin, *normalized_args) }
|
|
51
|
+
log_cli_result(args: normalized_args, success: status.success?, output: output)
|
|
52
|
+
rescue Errno::ENOENT
|
|
53
|
+
log_cli_result(args: args, success: false, output: "Command not found: #{bin}")
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
log_cli_result(args: args, success: false, output: "#{e.class}: #{e.message}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def normalize_command_args(args)
|
|
59
|
+
args.map { |arg| arg.is_a?(String) ? normalize_text(arg) : arg }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def log_cli_result(args:, success:, output:)
|
|
63
|
+
normalized_output = normalize_text(output)
|
|
64
|
+
logger.log_cli(bin: bin, args: args, success: success, output: normalized_output)
|
|
65
|
+
[success, normalized_output]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_time(value)
|
|
69
|
+
Time.parse(value)
|
|
70
|
+
rescue ArgumentError, TypeError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_rows(output)
|
|
75
|
+
rows = JSON.parse(output)
|
|
76
|
+
rows.is_a?(Array) ? rows : nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def build_entry(row)
|
|
80
|
+
Models::TimeEntry.new(
|
|
81
|
+
id: row['id'],
|
|
82
|
+
note: row['note'],
|
|
83
|
+
sheet: row['sheet'],
|
|
84
|
+
start_time: parse_time(row['start']),
|
|
85
|
+
end_time: parse_time(row['end'])
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_text(value)
|
|
90
|
+
value.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '').scrub('')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def with_unbundled_env
|
|
94
|
+
return yield unless defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
|
|
95
|
+
|
|
96
|
+
Bundler.with_unbundled_env do
|
|
97
|
+
return yield
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|