fewald-worklog 0.1.12 → 0.1.13
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/.version +1 -1
- data/worklog/cli.rb +34 -158
- data/worklog/configuration.rb +32 -0
- data/worklog/log_entry.rb +20 -4
- data/worklog/printer.rb +13 -8
- data/worklog/statistics.rb +9 -3
- data/worklog/storage.rb +22 -21
- data/worklog/string_helper.rb +8 -0
- data/worklog/summary.rb +2 -0
- data/worklog/webserver.rb +16 -5
- data/worklog/worklog.rb +161 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 72fb884d4adc03a89030b3c7811070131c814b5ddc7f9e4bb2846b400fedb678
|
4
|
+
data.tar.gz: 24ff8173ce145bb1da3c1d9758626acb5c93c1b31bd4fadb785bd5dbed283ebe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db4fe81d42e70d1f8209d939bc676a3655a5fdcf8a095c048c551ea35f0057dc7e7b04bd8e8ee86ec90f9b65c7231eba2dac205a9dc8acc17e32ce0841bcb267
|
7
|
+
data.tar.gz: 93153a298dd1b4034bf7b632f117dbddc75f8d36bb741b90e230a14859ed40c7e18d44c0d1478253df3c937f7c465326d799ec9bbede358dead40ad551a4b985
|
data/.version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.1.
|
1
|
+
0.1.13
|
data/worklog/cli.rb
CHANGED
@@ -10,22 +10,32 @@ require 'logger'
|
|
10
10
|
|
11
11
|
require_relative 'worklog'
|
12
12
|
require 'date_parser'
|
13
|
-
|
14
|
-
require 'statistics'
|
15
|
-
require 'storage'
|
16
|
-
require 'webserver'
|
17
|
-
require_relative 'summary'
|
13
|
+
require_relative 'configuration'
|
18
14
|
require_relative 'editor'
|
15
|
+
require_relative 'printer'
|
16
|
+
require_relative 'statistics'
|
17
|
+
require_relative 'storage'
|
19
18
|
require_relative 'string_helper'
|
19
|
+
require_relative 'summary'
|
20
20
|
require_relative 'version'
|
21
|
+
require_relative 'webserver'
|
21
22
|
|
22
23
|
# CLI for the work log application
|
23
24
|
class WorklogCLI < Thor
|
25
|
+
attr_accessor :config, :storage
|
26
|
+
|
24
27
|
include StringHelper
|
25
28
|
class_option :verbose, type: :boolean, aliases: '-v', desc: 'Enable verbose output'
|
26
29
|
|
27
30
|
package_name 'Worklog'
|
28
31
|
|
32
|
+
# Initialize the CLI with the given arguments, options, and configuration
|
33
|
+
def initialize(args = [], options = {}, config = {})
|
34
|
+
@config = load_configuration
|
35
|
+
@storage = Storage.new(@config)
|
36
|
+
super
|
37
|
+
end
|
38
|
+
|
29
39
|
def self.exit_on_failure?
|
30
40
|
true
|
31
41
|
end
|
@@ -45,71 +55,22 @@ class WorklogCLI < Thor
|
|
45
55
|
option :url, type: :string, desc: 'URL to associate with the entry'
|
46
56
|
option :epic, type: :boolean, default: false, desc: 'Mark the entry as an epic'
|
47
57
|
def add(message)
|
48
|
-
|
49
|
-
|
50
|
-
# Remove leading and trailing whitespaces
|
51
|
-
# Raise an error if the message is empty
|
52
|
-
message = message.strip
|
53
|
-
raise ArgumentError, 'Message cannot be empty' if message.empty?
|
54
|
-
|
55
|
-
date = Date.strptime(options[:date], '%Y-%m-%d')
|
56
|
-
time = Time.strptime(options[:time], '%H:%M:%S')
|
57
|
-
Storage.create_file_skeleton(date)
|
58
|
-
|
59
|
-
daily_log = Storage.load_log!(Storage.filepath(date))
|
60
|
-
daily_log.entries << LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
|
61
|
-
epic: options[:epic], message:)
|
62
|
-
|
63
|
-
# Sort by time in case an entry was added later out of order.
|
64
|
-
daily_log.entries.sort_by!(&:time)
|
65
|
-
|
66
|
-
Storage.write_log(Storage.filepath(options[:date]), daily_log)
|
67
|
-
|
68
|
-
WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
|
58
|
+
worklog = Worklog.new
|
59
|
+
worklog.add(message, options)
|
69
60
|
end
|
70
61
|
|
71
|
-
desc 'edit', 'Edit a
|
62
|
+
desc 'edit', 'Edit a day in the work log. By default, the current date is used.'
|
72
63
|
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
73
64
|
def edit
|
74
|
-
|
75
|
-
|
76
|
-
date = Date.strptime(options[:date], '%Y-%m-%d')
|
77
|
-
|
78
|
-
# Load existing log
|
79
|
-
log = Storage.load_log(Storage.filepath(date))
|
80
|
-
unless log
|
81
|
-
WorkLogger.error "No work log found for #{options[:date]}. Aborting."
|
82
|
-
exit 1
|
83
|
-
end
|
84
|
-
|
85
|
-
txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
|
86
|
-
return_val = Editor.open_editor(txt)
|
87
|
-
|
88
|
-
Storage.write_log(Storage.filepath(date),
|
89
|
-
YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
|
90
|
-
WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
|
65
|
+
worklog = Worklog.new
|
66
|
+
worklog.edit(options)
|
91
67
|
end
|
92
68
|
|
93
69
|
desc 'remove', 'Remove last entry from the log'
|
94
70
|
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
95
71
|
def remove
|
96
|
-
|
97
|
-
|
98
|
-
date = Date.strptime(options[:date], '%Y-%m-%d')
|
99
|
-
unless File.exist?(Storage.filepath(date))
|
100
|
-
WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
|
101
|
-
exit 1
|
102
|
-
end
|
103
|
-
|
104
|
-
daily_log = Storage.load_log!(Storage.filepath(options[:date]))
|
105
|
-
if daily_log.entries.empty?
|
106
|
-
WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
|
107
|
-
exit 1
|
108
|
-
end
|
109
|
-
|
110
|
-
removed_entry = daily_log.entries.pop
|
111
|
-
Storage.write_log(Storage.filepath(date), daily_log)
|
112
|
-
WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
|
72
|
+
worklog = Worklog.new
|
73
|
+
worklog.remove(options)
|
113
74
|
end
|
114
75
|
|
115
76
|
desc 'show', 'Show the work log for a specific date or a range of dates. Defaults to todays date.'
|
@@ -132,70 +93,32 @@ class WorklogCLI < Thor
|
|
132
93
|
option :epics_only, type: :boolean, default: false, desc: 'Show only entries that are marked as epic'
|
133
94
|
option :tags, type: :array, default: [], desc: 'Filter entries by tags. Tags are treated as an OR condition.'
|
134
95
|
def show
|
135
|
-
|
136
|
-
|
137
|
-
start_date, end_date = start_end_date(options)
|
138
|
-
|
139
|
-
entries = Storage.days_between(start_date, end_date)
|
140
|
-
if entries.empty?
|
141
|
-
Printer.no_entries(start_date, end_date)
|
142
|
-
else
|
143
|
-
entries.each do |entry|
|
144
|
-
Printer.print_day(entry, entries.size > 1, options[:epics_only])
|
145
|
-
end
|
146
|
-
end
|
96
|
+
worklog = Worklog.new
|
97
|
+
worklog.show(options)
|
147
98
|
end
|
148
99
|
|
149
100
|
desc 'people', 'Show all people mentioned in the work log'
|
150
101
|
def people
|
151
|
-
|
152
|
-
|
153
|
-
puts 'People mentioned in the work log:'
|
154
|
-
|
155
|
-
mentions = {}
|
156
|
-
all_logs = Storage.all_days
|
157
|
-
all_logs.map(&:people).each do |people|
|
158
|
-
mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
|
159
|
-
end
|
160
|
-
mentions.each { |k, v| puts "#{Rainbow(k).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
102
|
+
worklog = Worklog.new
|
103
|
+
worklog.people(options)
|
161
104
|
end
|
162
105
|
|
163
106
|
desc 'tags', 'Show all tags used in the work log'
|
164
107
|
def tags
|
165
|
-
|
166
|
-
|
167
|
-
all_logs = Storage.all_days
|
168
|
-
|
169
|
-
puts Rainbow('Tags used in the work log:').gold
|
170
|
-
|
171
|
-
# Count all tags used in the work log
|
172
|
-
tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally
|
173
|
-
|
174
|
-
# Determine length of longest tag for formatting
|
175
|
-
# Add one additonal space for formatting
|
176
|
-
max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1
|
177
|
-
|
178
|
-
tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
108
|
+
worklog = Worklog.new
|
109
|
+
worklog.tags(options)
|
179
110
|
end
|
180
111
|
|
181
112
|
desc 'server', 'Start the work log server'
|
182
113
|
def server
|
183
|
-
|
184
|
-
|
185
|
-
WorkLogServer.new.start
|
114
|
+
app = WorkLogApp.new(@storage)
|
115
|
+
WorkLogServer.new(app).start
|
186
116
|
end
|
187
117
|
|
188
118
|
desc 'stats', 'Show statistics for the work log'
|
189
119
|
def stats
|
190
|
-
|
191
|
-
|
192
|
-
stats = Statistics.calculate
|
193
|
-
puts "#{format_left('Total days')}: #{stats.total_days}"
|
194
|
-
puts "#{format_left('Total entries')}: #{stats.total_entries}"
|
195
|
-
puts "#{format_left('Total epics')}: #{stats.total_epics}"
|
196
|
-
puts "#{format_left('Entries per day')}: #{'%.2f' % stats.avg_entries}"
|
197
|
-
puts "#{format_left('First entry')}: #{stats.first_entry}"
|
198
|
-
puts "#{format_left('Last entry')}: #{stats.last_entry}"
|
120
|
+
worklog = Worklog.new
|
121
|
+
worklog.stats(options)
|
199
122
|
end
|
200
123
|
|
201
124
|
desc 'summary', 'Generate a summary of the work log entries'
|
@@ -210,23 +133,12 @@ class WorklogCLI < Thor
|
|
210
133
|
'Number of days to show starting from --date. Takes precedence over --from and --to if defined.'
|
211
134
|
EOF
|
212
135
|
def summary
|
213
|
-
|
214
|
-
|
215
|
-
start_date, end_date = start_end_date(options)
|
216
|
-
entries = Storage.days_between(start_date, end_date).map(&:entries).flatten
|
217
|
-
|
218
|
-
# Do nothing if no entries are found.
|
219
|
-
if entries.empty?
|
220
|
-
Printer.no_entries(start_date, end_date)
|
221
|
-
return
|
222
|
-
end
|
223
|
-
puts Summary.generate_summary(entries)
|
136
|
+
worklog = Worklog.new
|
137
|
+
worklog.summary(options)
|
224
138
|
end
|
225
139
|
|
226
140
|
desc 'version', 'Show the version of the Worklog'
|
227
141
|
def version
|
228
|
-
set_log_level
|
229
|
-
|
230
142
|
puts "Worklog #{current_version} running on Ruby #{RUBY_VERSION}"
|
231
143
|
end
|
232
144
|
|
@@ -234,40 +146,4 @@ class WorklogCLI < Thor
|
|
234
146
|
map 'a' => :add
|
235
147
|
map 'statistics' => :stats
|
236
148
|
map 'serve' => :server
|
237
|
-
|
238
|
-
no_commands do
|
239
|
-
def set_log_level
|
240
|
-
# Set the log level based on the verbose option
|
241
|
-
WorkLogger.level = options[:verbose] ? Logger::Severity::DEBUG : Logger::Severity::INFO
|
242
|
-
end
|
243
|
-
|
244
|
-
def format_left(string)
|
245
|
-
# Format a string to be left-aligned in a fixed-width field
|
246
|
-
#
|
247
|
-
# @param string [String] the string to format
|
248
|
-
# @return [String] the formatted string
|
249
|
-
format('%18s', string)
|
250
|
-
end
|
251
|
-
|
252
|
-
# Parse the start and end date based on the options provided
|
253
|
-
#
|
254
|
-
# @param options [Hash] the options hash
|
255
|
-
# @return [Array] the start and end date as an array
|
256
|
-
def start_end_date(options)
|
257
|
-
if options[:days]
|
258
|
-
# Safeguard against negative days
|
259
|
-
raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
|
260
|
-
|
261
|
-
start_date = Date.today - options[:days]
|
262
|
-
end_date = Date.today
|
263
|
-
elsif options[:from]
|
264
|
-
start_date = DateParser.parse_date_string!(options[:from], true)
|
265
|
-
end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
|
266
|
-
else
|
267
|
-
start_date = Date.strptime(options[:date], '%Y-%m-%d')
|
268
|
-
end_date = start_date
|
269
|
-
end
|
270
|
-
[start_date, end_date]
|
271
|
-
end
|
272
|
-
end
|
273
149
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
|
5
|
+
# Configuration class for the application.
|
6
|
+
class Configuration
|
7
|
+
attr_accessor :storage_path, :log_level, :webserver_port
|
8
|
+
|
9
|
+
def initialize(&block)
|
10
|
+
block.call(self) if block_given?
|
11
|
+
|
12
|
+
# Set default values if not set
|
13
|
+
@storage_path ||= File.join(Dir.home, '.worklog')
|
14
|
+
@log_level ||= :info
|
15
|
+
@webserver_port ||= 3000
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def load_configuration
|
20
|
+
# TODO: Implement loading configuration from a file
|
21
|
+
file_path = File.join(Dir.home, '.worklog.yaml')
|
22
|
+
if File.exist?(file_path)
|
23
|
+
file_cfg = YAML.load_file(file_path)
|
24
|
+
Configuration.new do |cfg|
|
25
|
+
cfg.storage_path = file_cfg['storage_path'] if file_cfg['storage_path']
|
26
|
+
cfg.log_level = file_cfg['log_level'].to_sym if file_cfg['log_level']
|
27
|
+
cfg.webserver_port = file_cfg['webserver_port'] if file_cfg['webserver_port']
|
28
|
+
end
|
29
|
+
else
|
30
|
+
puts "Configuration file does not exist in #{file_path}"
|
31
|
+
end
|
32
|
+
end
|
data/worklog/log_entry.rb
CHANGED
@@ -7,6 +7,8 @@ require_relative 'hash'
|
|
7
7
|
|
8
8
|
# A single log entry.
|
9
9
|
class LogEntry
|
10
|
+
PERSON_REGEX = /\s[~@](\w+)/
|
11
|
+
|
10
12
|
include Hashify
|
11
13
|
|
12
14
|
# Represents a single entry in the work log.
|
@@ -29,13 +31,27 @@ class LogEntry
|
|
29
31
|
end
|
30
32
|
|
31
33
|
# Returns the message string with formatting without the time.
|
32
|
-
|
34
|
+
# @param people Hash[String, Person] A hash of people with their handles as keys.
|
35
|
+
def message_string(known_people = nil)
|
36
|
+
# replace all mentions of people with their names.
|
37
|
+
msg = @message.dup
|
38
|
+
people.each do |person|
|
39
|
+
next unless known_people && known_people[person]
|
40
|
+
|
41
|
+
msg.gsub!(PERSON_REGEX) do |match|
|
42
|
+
s = ''
|
43
|
+
s += ' ' if match[0] == ' '
|
44
|
+
s += "#{Rainbow(known_people[person].name).underline} (~#{person})" if known_people && known_people[person]
|
45
|
+
s
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
33
49
|
s = ''
|
34
50
|
|
35
51
|
s += if epic
|
36
|
-
Rainbow("[EPIC] #{
|
52
|
+
Rainbow("[EPIC] #{msg}").bg(:white).fg(:black)
|
37
53
|
else
|
38
|
-
|
54
|
+
msg
|
39
55
|
end
|
40
56
|
|
41
57
|
s += " [#{Rainbow(@ticket).fg(:blue)}]" if @ticket
|
@@ -57,7 +73,7 @@ class LogEntry
|
|
57
73
|
# Empty array if no people are mentioned.
|
58
74
|
#
|
59
75
|
# @return [Array<String>]
|
60
|
-
@message.scan(
|
76
|
+
@message.scan(PERSON_REGEX).flatten.uniq.sort
|
61
77
|
end
|
62
78
|
|
63
79
|
def people?
|
data/worklog/printer.rb
CHANGED
@@ -3,11 +3,17 @@
|
|
3
3
|
require 'rainbow'
|
4
4
|
|
5
5
|
# Printer for work log entries
|
6
|
-
|
6
|
+
class Printer
|
7
|
+
attr_reader :people
|
8
|
+
|
9
|
+
def initialize(people = nil)
|
10
|
+
@people = (people || []).to_h { |person| [person.handle, person] }
|
11
|
+
end
|
12
|
+
|
7
13
|
# Prints a whole day of work log entries.
|
8
14
|
# If date_inline is true, the date is printed inline with the time.
|
9
15
|
# If epics_only is true, only epic entries are printed.
|
10
|
-
def
|
16
|
+
def print_day(daily_log, date_inline = false, epics_only = false)
|
11
17
|
daily_log.date = Date.strptime(daily_log.date, '%Y-%m-%d') unless daily_log.date.respond_to?(:strftime)
|
12
18
|
|
13
19
|
date_string = daily_log.date.strftime('%a, %B %-d, %Y')
|
@@ -24,7 +30,7 @@ module Printer
|
|
24
30
|
# @param start_date [Date]
|
25
31
|
# @param end_date [Date]
|
26
32
|
# @return [void]
|
27
|
-
def
|
33
|
+
def no_entries(start_date, end_date)
|
28
34
|
if start_date == end_date
|
29
35
|
date_string = start_date.strftime('%a, %B %-d, %Y')
|
30
36
|
puts "No entries found for #{Rainbow(date_string).gold}."
|
@@ -35,9 +41,10 @@ module Printer
|
|
35
41
|
end
|
36
42
|
end
|
37
43
|
|
38
|
-
private
|
39
|
-
|
40
44
|
# Prints a single entry, formats the date and time.
|
45
|
+
# @param daily_log [DailyLog]
|
46
|
+
# @param entry [LogEntry]
|
47
|
+
# @param date_inline [Boolean] If true, the date is printed inline with the time.
|
41
48
|
def print_entry(daily_log, entry, date_inline = false)
|
42
49
|
entry.time = DateTime.strptime(entry.time, '%H:%M:%S') unless entry.time.respond_to?(:strftime)
|
43
50
|
|
@@ -47,8 +54,6 @@ module Printer
|
|
47
54
|
entry.time.strftime('%H:%M')
|
48
55
|
end
|
49
56
|
|
50
|
-
puts "#{Rainbow(time_string).gold} #{entry.message_string}"
|
57
|
+
puts "#{Rainbow(time_string).gold} #{entry.message_string(@people)}"
|
51
58
|
end
|
52
|
-
|
53
|
-
module_function :print_entry
|
54
59
|
end
|
data/worklog/statistics.rb
CHANGED
@@ -6,11 +6,17 @@ require_relative 'storage'
|
|
6
6
|
STATS = Data.define(:total_days, :total_entries, :total_epics, :avg_entries, :first_entry, :last_entry)
|
7
7
|
|
8
8
|
# Module for calculating statistics for the work log.
|
9
|
-
|
9
|
+
class Statistics
|
10
|
+
# Initialize the Statistics class.
|
11
|
+
def initialize(config)
|
12
|
+
@config = config
|
13
|
+
@storage = Storage.new(config)
|
14
|
+
end
|
15
|
+
|
10
16
|
# Calculate statistics for the work log for all days.
|
11
17
|
# @return [STATS] The statistics for the work log
|
12
|
-
def
|
13
|
-
all_entries =
|
18
|
+
def calculate
|
19
|
+
all_entries = @storage.all_days
|
14
20
|
return STATS.new(0, 0, 0, 0, Date.today, Date.today) if all_entries.empty?
|
15
21
|
|
16
22
|
total_days = all_entries.length
|
data/worklog/storage.rb
CHANGED
@@ -2,27 +2,32 @@
|
|
2
2
|
|
3
3
|
require 'rainbow'
|
4
4
|
require_relative 'daily_log'
|
5
|
+
require_relative 'log_entry'
|
5
6
|
require_relative 'logger'
|
6
7
|
require_relative 'person'
|
7
8
|
|
8
|
-
|
9
|
+
class Storage
|
9
10
|
# LogNotFoundError is raised when a log file is not found
|
10
11
|
class LogNotFoundError < StandardError; end
|
11
12
|
|
12
13
|
FILE_SUFFIX = '.yaml'
|
13
14
|
DATA_DIR = File.join(Dir.home, '.worklog')
|
14
15
|
|
15
|
-
def
|
16
|
-
|
16
|
+
def initialize(config)
|
17
|
+
@config = config
|
18
|
+
end
|
19
|
+
|
20
|
+
def folder_exists?
|
21
|
+
Dir.exist?(@config.storage_path)
|
17
22
|
end
|
18
23
|
|
19
24
|
# Return all days with logs
|
20
25
|
# @return [Array<DailyLog>] List of logs
|
21
|
-
def
|
26
|
+
def all_days
|
22
27
|
return [] unless folder_exists?
|
23
28
|
|
24
29
|
logs = []
|
25
|
-
Dir.glob(File.join(
|
30
|
+
Dir.glob(File.join(@config.storage_path, "*#{FILE_SUFFIX}")).map do |file|
|
26
31
|
next if file.end_with?('people.yaml')
|
27
32
|
|
28
33
|
logs << load_log(file)
|
@@ -37,8 +42,8 @@ module Storage
|
|
37
42
|
# @param [Date] start_date The start date, inclusive
|
38
43
|
# @param [Date] end_date The end date, inclusive
|
39
44
|
# @param [Boolean] epics_only If true, only return logs with epic entries
|
40
|
-
# @param [Array] tags_filter If provided, only return logs with entries that have at least one of the tags
|
41
|
-
def
|
45
|
+
# @param [Array<String>] tags_filter If provided, only return logs with entries that have at least one of the tags
|
46
|
+
def days_between(start_date, end_date = nil, epics_only = nil, tags_filter = nil)
|
42
47
|
return [] unless folder_exists?
|
43
48
|
|
44
49
|
logs = []
|
@@ -66,20 +71,20 @@ module Storage
|
|
66
71
|
|
67
72
|
# Create file for a new day if it does not exist
|
68
73
|
# @param [Date] date The date, used as the file name.
|
69
|
-
def
|
74
|
+
def create_file_skeleton(date)
|
70
75
|
create_folder
|
71
76
|
|
72
77
|
File.write(filepath(date), YAML.dump(DailyLog.new(date:, entries: []))) unless File.exist?(filepath(date))
|
73
78
|
end
|
74
79
|
|
75
|
-
def
|
80
|
+
def load_log(file)
|
76
81
|
load_log!(file)
|
77
82
|
rescue LogNotFoundError
|
78
83
|
WorkLogger.error "No work log found for #{file}. Aborting."
|
79
84
|
nil
|
80
85
|
end
|
81
86
|
|
82
|
-
def
|
87
|
+
def load_log!(file)
|
83
88
|
WorkLogger.debug "Loading file #{file}"
|
84
89
|
begin
|
85
90
|
log = YAML.load_file(file, permitted_classes: [Date, Time, DailyLog, LogEntry])
|
@@ -92,7 +97,7 @@ module Storage
|
|
92
97
|
end
|
93
98
|
end
|
94
99
|
|
95
|
-
def
|
100
|
+
def write_log(file, daily_log)
|
96
101
|
create_folder
|
97
102
|
|
98
103
|
WorkLogger.debug "Writing to file #{file}"
|
@@ -102,7 +107,7 @@ module Storage
|
|
102
107
|
end
|
103
108
|
end
|
104
109
|
|
105
|
-
def
|
110
|
+
def load_single_log_file(file, headline = true)
|
106
111
|
daily_log = load_log!(file)
|
107
112
|
puts "Work log for #{Rainbow(daily_log.date).gold}:" if headline
|
108
113
|
daily_log.entries
|
@@ -110,7 +115,7 @@ module Storage
|
|
110
115
|
|
111
116
|
# Load all people from the people file
|
112
117
|
# @return [Array<Person>] List of people
|
113
|
-
def
|
118
|
+
def load_people!
|
114
119
|
people_file = File.join(DATA_DIR, 'people.yaml')
|
115
120
|
return [] unless File.exist?(people_file)
|
116
121
|
|
@@ -119,28 +124,24 @@ module Storage
|
|
119
124
|
|
120
125
|
# Write people to the people file
|
121
126
|
# @param [Array<Person>] people List of people
|
122
|
-
def
|
127
|
+
def write_people!(people)
|
123
128
|
create_folder
|
124
129
|
|
125
|
-
people_file = File.join(
|
130
|
+
people_file = File.join(@config.storage_path, 'people.yaml')
|
126
131
|
File.open(people_file, 'w') do |f|
|
127
132
|
f.puts people.to_yaml
|
128
133
|
end
|
129
134
|
end
|
130
135
|
|
131
|
-
private
|
132
|
-
|
133
136
|
# Create folder if not exists already.
|
134
137
|
def create_folder
|
135
|
-
Dir.mkdir(
|
138
|
+
Dir.mkdir(@config.storage_path) unless Dir.exist?(@config.storage_path)
|
136
139
|
end
|
137
140
|
|
138
141
|
# Construct filepath for a given date.
|
139
142
|
# @param [Date] date The date
|
140
143
|
# @return [String] The filepath
|
141
144
|
def filepath(date)
|
142
|
-
File.join(
|
145
|
+
File.join(@config.storage_path, "#{date}#{FILE_SUFFIX}")
|
143
146
|
end
|
144
|
-
|
145
|
-
module_function :create_folder, :filepath
|
146
147
|
end
|
data/worklog/string_helper.rb
CHANGED
@@ -24,4 +24,12 @@ module StringHelper
|
|
24
24
|
"#{singular}s"
|
25
25
|
end
|
26
26
|
end
|
27
|
+
|
28
|
+
# Format a string to be left-aligned in a fixed-width field
|
29
|
+
#
|
30
|
+
# @param string [String] the string to format
|
31
|
+
# @return [String] the formatted string
|
32
|
+
def format_left(string)
|
33
|
+
format('%18s', string)
|
34
|
+
end
|
27
35
|
end
|
data/worklog/summary.rb
CHANGED
@@ -47,6 +47,8 @@ module Summary
|
|
47
47
|
end
|
48
48
|
|
49
49
|
# Generate a summary from provided log entries.
|
50
|
+
# @param log_entries [Array<LogEntry>] The log entries to summarize.
|
51
|
+
# @return [String] The generated summary.
|
50
52
|
def self.generate_summary(log_entries)
|
51
53
|
prompt = build_prompt(log_entries)
|
52
54
|
|
data/worklog/webserver.rb
CHANGED
@@ -24,8 +24,11 @@ class DefaultHeaderMiddleware
|
|
24
24
|
end
|
25
25
|
end
|
26
26
|
|
27
|
+
# Class to render the main page of the WorkLog web application.
|
27
28
|
class WorkLogResponse
|
28
|
-
|
29
|
+
def initialize(storage)
|
30
|
+
@storage = storage
|
31
|
+
end
|
29
32
|
|
30
33
|
def response(request)
|
31
34
|
template = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'index.html.erb')), trim_mode: '-')
|
@@ -34,7 +37,7 @@ class WorkLogResponse
|
|
34
37
|
tags = @params['tags'].nil? ? nil : @params['tags'].split(',')
|
35
38
|
epics_only = @params['epics_only'] == 'true'
|
36
39
|
presentation = @params['presentation'] == 'true'
|
37
|
-
logs =
|
40
|
+
logs = @storage.days_between(Date.today - days, Date.today, epics_only, tags).reverse
|
38
41
|
total_entries = logs.sum { |entry| entry.entries.length }
|
39
42
|
_ = total_entries
|
40
43
|
_ = presentation
|
@@ -62,9 +65,13 @@ class WorkLogResponse
|
|
62
65
|
end
|
63
66
|
|
64
67
|
class WorkLogApp
|
65
|
-
def
|
68
|
+
def initialize(storage)
|
69
|
+
@storage = storage
|
70
|
+
end
|
71
|
+
|
72
|
+
def call(env)
|
66
73
|
req = Rack::Request.new(env)
|
67
|
-
WorkLogResponse.new.response(req)
|
74
|
+
WorkLogResponse.new(@storage).response(req)
|
68
75
|
end
|
69
76
|
end
|
70
77
|
|
@@ -79,8 +86,12 @@ end
|
|
79
86
|
|
80
87
|
class WorkLogServer
|
81
88
|
# Main Rack server containing all endpoints.
|
89
|
+
def initialize(worklog_app)
|
90
|
+
@worklog_app = worklog_app
|
91
|
+
end
|
82
92
|
|
83
93
|
def start
|
94
|
+
worklog_app = @worklog_app
|
84
95
|
app = Rack::Builder.new do
|
85
96
|
use Rack::Deflater
|
86
97
|
use Rack::CommonLogger
|
@@ -89,7 +100,7 @@ class WorkLogServer
|
|
89
100
|
use DefaultHeaderMiddleware
|
90
101
|
|
91
102
|
map '/' do
|
92
|
-
run
|
103
|
+
run worklog_app
|
93
104
|
end
|
94
105
|
# TODO: Future development
|
95
106
|
# map '/favicon.svg' do
|
data/worklog/worklog.rb
CHANGED
@@ -7,8 +7,168 @@ require 'yaml'
|
|
7
7
|
|
8
8
|
require_relative 'hash'
|
9
9
|
require_relative 'daily_log'
|
10
|
+
require_relative 'date_parser'
|
10
11
|
require_relative 'log_entry'
|
11
12
|
require_relative 'storage'
|
13
|
+
require_relative 'logger'
|
14
|
+
require_relative 'string_helper'
|
15
|
+
require_relative 'printer'
|
16
|
+
require_relative 'statistics'
|
12
17
|
|
13
|
-
|
18
|
+
# Main class providing all worklog functionality.
|
19
|
+
# This class is the main entry point for the application.
|
20
|
+
# It handles command line arguments, configuration, and logging.
|
21
|
+
class Worklog
|
22
|
+
include StringHelper
|
23
|
+
attr_reader :config
|
24
|
+
|
25
|
+
def initialize(config = nil)
|
26
|
+
@config = config || Configuration.new
|
27
|
+
@storage = Storage.new(@config)
|
28
|
+
|
29
|
+
WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO
|
30
|
+
end
|
31
|
+
|
32
|
+
def add(message, options = {})
|
33
|
+
# Remove leading and trailing whitespaces
|
34
|
+
# Raise an error if the message is empty
|
35
|
+
message = message.strip
|
36
|
+
raise ArgumentError, 'Message cannot be empty' if message.empty?
|
37
|
+
|
38
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
39
|
+
time = Time.strptime(options[:time], '%H:%M:%S')
|
40
|
+
@storage.create_file_skeleton(date)
|
41
|
+
|
42
|
+
daily_log = @storage.load_log!(@storage.filepath(date))
|
43
|
+
daily_log.entries << LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
|
44
|
+
epic: options[:epic], message:)
|
45
|
+
|
46
|
+
# Sort by time in case an entry was added later out of order.
|
47
|
+
daily_log.entries.sort_by!(&:time)
|
48
|
+
|
49
|
+
@storage.write_log(@storage.filepath(options[:date]), daily_log)
|
50
|
+
|
51
|
+
WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
|
52
|
+
end
|
53
|
+
|
54
|
+
def edit(options = {})
|
55
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
56
|
+
|
57
|
+
# Load existing log
|
58
|
+
log = @storage.load_log(@storage.filepath(date))
|
59
|
+
unless log
|
60
|
+
WorkLogger.error "No work log found for #{options[:date]}. Aborting."
|
61
|
+
exit 1
|
62
|
+
end
|
63
|
+
|
64
|
+
txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
|
65
|
+
return_val = Editor.open_editor(txt)
|
66
|
+
|
67
|
+
@storage.write_log(@storage.filepath(date),
|
68
|
+
YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
|
69
|
+
WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
|
70
|
+
end
|
71
|
+
|
72
|
+
def show(options = {})
|
73
|
+
people = @storage.load_people!
|
74
|
+
printer = Printer.new(people)
|
75
|
+
|
76
|
+
start_date, end_date = start_end_date(options)
|
77
|
+
|
78
|
+
entries = @storage.days_between(start_date, end_date)
|
79
|
+
if entries.empty?
|
80
|
+
printer.no_entries(start_date, end_date)
|
81
|
+
else
|
82
|
+
entries.each do |entry|
|
83
|
+
printer.print_day(entry, entries.size > 1, options[:epics_only])
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def people(_options = {})
|
89
|
+
puts 'People mentioned in the work log:'
|
90
|
+
|
91
|
+
mentions = {}
|
92
|
+
all_logs = @storage.all_days
|
93
|
+
all_logs.map(&:people).each do |people|
|
94
|
+
mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
|
95
|
+
end
|
96
|
+
mentions.each { |k, v| puts "#{Rainbow(k).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
97
|
+
end
|
98
|
+
|
99
|
+
def tags(_options = {})
|
100
|
+
all_logs = @storage.all_days
|
101
|
+
|
102
|
+
puts Rainbow('Tags used in the work log:').gold
|
103
|
+
|
104
|
+
# Count all tags used in the work log
|
105
|
+
tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally
|
106
|
+
|
107
|
+
# Determine length of longest tag for formatting
|
108
|
+
# Add one additonal space for formatting
|
109
|
+
max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1
|
110
|
+
|
111
|
+
tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
112
|
+
end
|
113
|
+
|
114
|
+
def stats(_options = {})
|
115
|
+
stats = Statistics.new(@config).calculate
|
116
|
+
puts "#{format_left('Total days')}: #{stats.total_days}"
|
117
|
+
puts "#{format_left('Total entries')}: #{stats.total_entries}"
|
118
|
+
puts "#{format_left('Total epics')}: #{stats.total_epics}"
|
119
|
+
puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
|
120
|
+
puts "#{format_left('First entry')}: #{stats.first_entry}"
|
121
|
+
puts "#{format_left('Last entry')}: #{stats.last_entry}"
|
122
|
+
end
|
123
|
+
|
124
|
+
def summary(options = {})
|
125
|
+
start_date, end_date = start_end_date(options)
|
126
|
+
entries = @storage.days_between(start_date, end_date).map(&:entries).flatten
|
127
|
+
|
128
|
+
# Do nothing if no entries are found.
|
129
|
+
if entries.empty?
|
130
|
+
Printer.new.no_entries(start_date, end_date)
|
131
|
+
return
|
132
|
+
end
|
133
|
+
puts Summary.generate_summary(entries)
|
134
|
+
end
|
135
|
+
|
136
|
+
def remove(options = {})
|
137
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
138
|
+
unless File.exist?(@storage.filepath(date))
|
139
|
+
WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
|
140
|
+
exit 1
|
141
|
+
end
|
142
|
+
|
143
|
+
daily_log = @storage.load_log!(@storage.filepath(options[:date]))
|
144
|
+
if daily_log.entries.empty?
|
145
|
+
WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
|
146
|
+
exit 1
|
147
|
+
end
|
148
|
+
|
149
|
+
removed_entry = daily_log.entries.pop
|
150
|
+
@storage.write_log(@storage.filepath(date), daily_log)
|
151
|
+
WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
|
152
|
+
end
|
153
|
+
|
154
|
+
# Parse the start and end date based on the options provided
|
155
|
+
#
|
156
|
+
# @param options [Hash] the options hash
|
157
|
+
# @return [Array] the start and end date as an array
|
158
|
+
def start_end_date(options)
|
159
|
+
if options[:days]
|
160
|
+
# Safeguard against negative days
|
161
|
+
raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
|
162
|
+
|
163
|
+
start_date = Date.today - options[:days]
|
164
|
+
end_date = Date.today
|
165
|
+
elsif options[:from]
|
166
|
+
start_date = DateParser.parse_date_string!(options[:from], true)
|
167
|
+
end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
|
168
|
+
else
|
169
|
+
start_date = Date.strptime(options[:date], '%Y-%m-%d')
|
170
|
+
end_date = start_date
|
171
|
+
end
|
172
|
+
[start_date, end_date]
|
173
|
+
end
|
14
174
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fewald-worklog
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.1.
|
4
|
+
version: 0.1.13
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Friedrich Ewald
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-04-
|
10
|
+
date: 2025-04-15 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: httparty
|
@@ -109,6 +109,7 @@ files:
|
|
109
109
|
- ".version"
|
110
110
|
- bin/wl
|
111
111
|
- worklog/cli.rb
|
112
|
+
- worklog/configuration.rb
|
112
113
|
- worklog/daily_log.rb
|
113
114
|
- worklog/date_parser.rb
|
114
115
|
- worklog/editor.rb
|