fewald-worklog 0.2.9 → 0.2.11
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/lib/cli.rb +17 -9
- data/lib/daily_log.rb +1 -1
- data/lib/date_parser.rb +5 -0
- data/lib/log_entry.rb +14 -9
- data/lib/project.rb +47 -0
- data/lib/project_storage.rb +78 -0
- data/lib/storage.rb +9 -0
- data/lib/worklog.rb +267 -191
- metadata +3 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d1bb6e2ee81700ee384981acfa68bfde33ba9d2e5e7d0af30b0c08c392bde042
|
4
|
+
data.tar.gz: ac0fa2e1c558f97bc11cdcb145e5a1b8eb12fd0ca661dd932f545108b0386732
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5dcae260eadbe46143a35b2dab7a6c3e780104d5b95dbeef0b439f52613e2dcc4ffa014d2963947715700feca90a88ec9c814fa5a7b90bea56068262b8bd8891
|
7
|
+
data.tar.gz: a4eedd1a9d6e7395fdfd7a758c90548e1eccc62195c80fb6475433500c4a376b67b3ba1e6ab857f9048234b643670f9b814908941093f9881be3d8d4aedb31b3
|
data/.version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.2.
|
1
|
+
0.2.11
|
data/lib/cli.rb
CHANGED
@@ -53,22 +53,23 @@ class WorklogCLI < Thor
|
|
53
53
|
option :ticket, type: :string, desc: 'Ticket number associated with the entry. Can be any alphanumeric string.'
|
54
54
|
option :url, type: :string, desc: 'URL to associate with the entry'
|
55
55
|
option :epic, type: :boolean, default: false, desc: 'Mark the entry as an epic'
|
56
|
+
option :project, type: :string, desc: 'Key of the project. The project needs to be defined first.'
|
56
57
|
def add(message)
|
57
|
-
worklog = Worklog.new
|
58
|
+
worklog = Worklog::Worklog.new
|
58
59
|
worklog.add(message, options)
|
59
60
|
end
|
60
61
|
|
61
62
|
desc 'edit', 'Edit a day in the work log. By default, the current date is used.'
|
62
63
|
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
63
64
|
def edit
|
64
|
-
worklog = Worklog.new
|
65
|
+
worklog = Worklog::Worklog.new
|
65
66
|
worklog.edit(options)
|
66
67
|
end
|
67
68
|
|
68
69
|
desc 'remove', 'Remove last entry from the log'
|
69
70
|
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
|
70
71
|
def remove
|
71
|
-
worklog = Worklog.new
|
72
|
+
worklog = Worklog::Worklog.new
|
72
73
|
worklog.remove(options)
|
73
74
|
end
|
74
75
|
|
@@ -92,16 +93,22 @@ class WorklogCLI < Thor
|
|
92
93
|
option :epics_only, type: :boolean, default: false, desc: 'Show only entries that are marked as epic'
|
93
94
|
option :tags, type: :array, default: [], desc: 'Filter entries by tags. Tags are treated as an OR condition.'
|
94
95
|
def show
|
95
|
-
worklog = Worklog.new
|
96
|
+
worklog = Worklog::Worklog.new
|
96
97
|
worklog.show(options)
|
97
98
|
end
|
98
99
|
|
99
100
|
desc 'people', 'Show all people mentioned in the work log'
|
100
101
|
def people(person = nil)
|
101
|
-
worklog = Worklog.new
|
102
|
+
worklog = Worklog::Worklog.new
|
102
103
|
worklog.people(person, options)
|
103
104
|
end
|
104
105
|
|
106
|
+
desc 'projects', 'Show all projects defined in the work log'
|
107
|
+
def projects(options = {})
|
108
|
+
worklog = Worklog::Worklog.new
|
109
|
+
worklog.projects(options)
|
110
|
+
end
|
111
|
+
|
105
112
|
desc 'tags', 'Show all tags used in the work log'
|
106
113
|
option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'),
|
107
114
|
desc: <<~DESC
|
@@ -117,19 +124,19 @@ class WorklogCLI < Thor
|
|
117
124
|
Number of days to show starting from --date. Takes precedence over --from and --to if defined.
|
118
125
|
EOF
|
119
126
|
def tags(tag = nil)
|
120
|
-
worklog = Worklog.new
|
127
|
+
worklog = Worklog::Worklog.new
|
121
128
|
worklog.tags(tag, options)
|
122
129
|
end
|
123
130
|
|
124
131
|
desc 'server', 'Start the work log server'
|
125
132
|
def server
|
126
|
-
worklog = Worklog.new
|
133
|
+
worklog = Worklog::Worklog.new
|
127
134
|
worklog.server
|
128
135
|
end
|
129
136
|
|
130
137
|
desc 'stats', 'Show statistics for the work log'
|
131
138
|
def stats
|
132
|
-
worklog = Worklog.new
|
139
|
+
worklog = Worklog::Worklog.new
|
133
140
|
worklog.stats(options)
|
134
141
|
end
|
135
142
|
|
@@ -145,7 +152,7 @@ class WorklogCLI < Thor
|
|
145
152
|
'Number of days to show starting from --date. Takes precedence over --from and --to if defined.'
|
146
153
|
EOF
|
147
154
|
def summary
|
148
|
-
worklog = Worklog.new
|
155
|
+
worklog = Worklog::Worklog.new
|
149
156
|
worklog.summary(options)
|
150
157
|
end
|
151
158
|
|
@@ -158,4 +165,5 @@ class WorklogCLI < Thor
|
|
158
165
|
map 'a' => :add
|
159
166
|
map 'statistics' => :stats
|
160
167
|
map 'serve' => :server
|
168
|
+
map 'project' => :projects
|
161
169
|
end
|
data/lib/daily_log.rb
CHANGED
data/lib/date_parser.rb
CHANGED
@@ -4,6 +4,10 @@ require 'date'
|
|
4
4
|
|
5
5
|
module DateParser
|
6
6
|
# Best effort date parsing from multiple formats.
|
7
|
+
#
|
8
|
+
# @param date_str [String] The date string to parse.
|
9
|
+
# @param from_beginning [Boolean] If true, returns the beginning of the date (e.g., first day of month or year).
|
10
|
+
# @return [Date, nil] Returns a Date object if parsing is successful, or nil if parsing fails.
|
7
11
|
def self.parse_date_string(date_str, from_beginning = true)
|
8
12
|
return nil if date_str.nil?
|
9
13
|
return nil if date_str.empty?
|
@@ -60,6 +64,7 @@ module DateParser
|
|
60
64
|
Date.new(d.year, d.month + 2, -1)
|
61
65
|
end
|
62
66
|
|
67
|
+
# Similar to parse_date_string, but raises an error if parsing fails.
|
63
68
|
def self.parse_date_string!(date_str, from_beginning = true)
|
64
69
|
date = parse_date_string(date_str, from_beginning)
|
65
70
|
raise ArgumentError, "Could not parse date string: \"#{date_str}\"" if date.nil?
|
data/lib/log_entry.rb
CHANGED
@@ -12,7 +12,9 @@ class LogEntry
|
|
12
12
|
include Hashify
|
13
13
|
|
14
14
|
# Represents a single entry in the work log.
|
15
|
-
attr_accessor :time, :tags, :ticket, :url, :epic, :message
|
15
|
+
attr_accessor :time, :tags, :ticket, :url, :epic, :message, :project
|
16
|
+
|
17
|
+
attr_reader :day
|
16
18
|
|
17
19
|
def initialize(params = {})
|
18
20
|
@time = params[:time]
|
@@ -23,6 +25,10 @@ class LogEntry
|
|
23
25
|
@url = params[:url] || ''
|
24
26
|
@epic = params[:epic]
|
25
27
|
@message = params[:message]
|
28
|
+
@project = params[:project]
|
29
|
+
|
30
|
+
# Back reference to the day
|
31
|
+
@day = params[:day] || nil
|
26
32
|
end
|
27
33
|
|
28
34
|
# Returns true if the entry is an epic, false otherwise.
|
@@ -62,18 +68,17 @@ class LogEntry
|
|
62
68
|
# Add URL in brackets if defined.
|
63
69
|
s += " [#{@url}]" if @url && @url != ''
|
64
70
|
|
71
|
+
s += " [#{@project}]" if @project && @project != ''
|
72
|
+
|
65
73
|
s
|
66
74
|
end
|
67
75
|
|
68
76
|
def people
|
69
|
-
# Return people that are mentioned in the entry.
|
70
|
-
#
|
71
|
-
#
|
72
|
-
#
|
73
|
-
|
74
|
-
#
|
75
|
-
# @return [Array<String>]
|
76
|
-
@message.scan(PERSON_REGEX).flatten.uniq.sort
|
77
|
+
# Return people that are mentioned in the entry. People are defined as character sequences
|
78
|
+
# starting with @ or ~. Whitespaces are used to separate people. Punctuation is ignored.
|
79
|
+
# Empty set if no people are mentioned.
|
80
|
+
# @return [Set<String>]
|
81
|
+
@message.scan(PERSON_REGEX).flatten.uniq.sort.to_set
|
77
82
|
end
|
78
83
|
|
79
84
|
def people?
|
data/lib/project.rb
ADDED
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Worklog
|
4
|
+
# Represents a project. A project is a longer running task or initiative.
|
5
|
+
# Single log entries can be associated with a project.
|
6
|
+
class Project
|
7
|
+
attr_accessor :key, :name, :description, :start_date, :end_date, :status
|
8
|
+
|
9
|
+
# Creates a new Project instance from a hash of attributes.
|
10
|
+
# @param hash [Hash] A hash containing project attributes
|
11
|
+
# @option hash [String] :key The project key
|
12
|
+
# @option hash [String] :name The project name
|
13
|
+
# @option hash [String] :description The project description
|
14
|
+
# @option hash [Date] :start_date The project start date
|
15
|
+
# @option hash [Date] :end_date The project end date
|
16
|
+
# @option hash [String] :status The project status
|
17
|
+
# @return [Project] A new Project instance
|
18
|
+
def self.from_hash(hash)
|
19
|
+
project = new
|
20
|
+
# Ensure that at least the key is present
|
21
|
+
raise ArgumentError, 'Project key is required' unless hash[:key] || hash['key']
|
22
|
+
|
23
|
+
project.key = hash[:key] || hash['key']
|
24
|
+
project.name = hash[:name] || hash['name']
|
25
|
+
project.description = hash[:description] || hash['description']
|
26
|
+
project.start_date = hash[:start_date] || hash['start_date']
|
27
|
+
project.end_date = hash[:end_date] || hash['end_date']
|
28
|
+
project.status = hash[:status] || hash['status']
|
29
|
+
project
|
30
|
+
end
|
31
|
+
|
32
|
+
# Returns true if the project has started, false otherwise.
|
33
|
+
# A project is considered started if either
|
34
|
+
# - its start date is nil or
|
35
|
+
# - its start date is less than or equal to today's date.
|
36
|
+
# @return [Boolean] true if the project has started, false otherwise
|
37
|
+
def started?
|
38
|
+
start_date.nil? || (!start_date.nil? && start_date <= Date.today)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Returns true if the project has ended, false otherwise.
|
42
|
+
# @return [Boolean] true if the project has ended, false otherwise
|
43
|
+
def ended?
|
44
|
+
!end_date.nil? && end_date < Date.today
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'project'
|
4
|
+
|
5
|
+
module Worklog
|
6
|
+
# Custom error for when a project is not found.
|
7
|
+
# This error is raised when a project with a given key does not exist in the project storage.
|
8
|
+
# It is used to signal that an operation requiring a project cannot proceed.
|
9
|
+
class ProjectNotFoundError < StandardError; end
|
10
|
+
|
11
|
+
# ProjectStorage is responsible for loading and managing project data.
|
12
|
+
# It provides methods to load projects from a YAML file and check if a project exists.
|
13
|
+
#
|
14
|
+
# @see Project
|
15
|
+
# @see Configuration
|
16
|
+
# Handles storage operations for projects.
|
17
|
+
class ProjectStorage
|
18
|
+
attr_writer :projects
|
19
|
+
|
20
|
+
PROJECT_TEMPLATE = <<~YAML
|
21
|
+
# Each project is defined by the following attributes:
|
22
|
+
# - key: <project_key>
|
23
|
+
# name: <project_name>
|
24
|
+
# description: <project_description>
|
25
|
+
# start_date: <start_date>
|
26
|
+
# end_date: <end_date>
|
27
|
+
# status: <status>
|
28
|
+
# --- Define your projects below this line ---
|
29
|
+
YAML
|
30
|
+
|
31
|
+
# Constructs a new ProjectStorage instance.
|
32
|
+
# @param configuration [Configuration] The configuration object.
|
33
|
+
def initialize(configuration)
|
34
|
+
@configuration = configuration
|
35
|
+
end
|
36
|
+
|
37
|
+
def projects
|
38
|
+
@projects ||= load_projects
|
39
|
+
end
|
40
|
+
|
41
|
+
# Loads all projects from disk.
|
42
|
+
# If the file does not exist, it creates a template.
|
43
|
+
# @return [Hash<String, Project>] A hash of project objects keyed by their project keys.
|
44
|
+
def load_projects
|
45
|
+
create_template unless file_exist?
|
46
|
+
|
47
|
+
file_path = File.join(@configuration.storage_path, 'projects.yml')
|
48
|
+
projects = {}
|
49
|
+
YAML.load_file(file_path, permitted_classes: [Date])&.each do |project_hash|
|
50
|
+
project = Project.from_hash(project_hash)
|
51
|
+
projects[project.key] = project if project
|
52
|
+
end
|
53
|
+
|
54
|
+
projects
|
55
|
+
end
|
56
|
+
|
57
|
+
# Check if a project with a given handle exists.
|
58
|
+
# @param handle [String] The handle of the project to check.
|
59
|
+
# @return [Boolean] Returns true if the project exists, false otherwise.
|
60
|
+
def exist?(handle)
|
61
|
+
projects = load_projects
|
62
|
+
projects.key?(handle)
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
# Check whether projects.yaml exists in the project_dir
|
68
|
+
# @return [Boolean] Returns true if the project YAML file exists, false otherwise
|
69
|
+
def file_exist?
|
70
|
+
file_path = File.join(@configuration.storage_path, 'projects.yml')
|
71
|
+
File.exist?(file_path)
|
72
|
+
end
|
73
|
+
|
74
|
+
def create_template
|
75
|
+
File.write(File.join(@configuration.storage_path, 'projects.yml'), PROJECT_TEMPLATE)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
data/lib/storage.rb
CHANGED
@@ -131,6 +131,9 @@ class Storage
|
|
131
131
|
daily_log.entries
|
132
132
|
end
|
133
133
|
|
134
|
+
# Load all people from the people file, or return an empty array if the file does not exist
|
135
|
+
#
|
136
|
+
# @return [Array<Person>] List of people
|
134
137
|
def load_people
|
135
138
|
load_people!
|
136
139
|
rescue Errno::ENOENT
|
@@ -138,6 +141,12 @@ class Storage
|
|
138
141
|
[]
|
139
142
|
end
|
140
143
|
|
144
|
+
# Load all people from the people file and return them as a hash with handle as key
|
145
|
+
# @return [Hash<String, Person>] Hash of people with handle as key
|
146
|
+
def load_people_hash
|
147
|
+
load_people.to_h { |person| [person.handle, person] }
|
148
|
+
end
|
149
|
+
|
141
150
|
# Load all people from the people file
|
142
151
|
# @return [Array<Person>] List of people
|
143
152
|
def load_people!
|
data/lib/worklog.rb
CHANGED
@@ -15,250 +15,326 @@ require 'string_helper'
|
|
15
15
|
require 'printer'
|
16
16
|
require 'statistics'
|
17
17
|
require 'summary'
|
18
|
+
require 'project_storage'
|
18
19
|
|
19
|
-
|
20
|
-
#
|
21
|
-
#
|
22
|
-
|
23
|
-
|
24
|
-
|
20
|
+
module Worklog
|
21
|
+
# Main class providing all worklog functionality.
|
22
|
+
# This class is the main entry point for the application.
|
23
|
+
# It handles command line arguments, configuration, and logging.
|
24
|
+
class Worklog
|
25
|
+
include StringHelper
|
26
|
+
attr_reader :config, :storage
|
25
27
|
|
26
|
-
|
27
|
-
|
28
|
-
|
28
|
+
def initialize(config = nil)
|
29
|
+
@config = config || Configuration.new
|
30
|
+
@storage = Storage.new(@config)
|
29
31
|
|
30
|
-
|
31
|
-
|
32
|
+
WorkLogger.level = @config.log_level == :debug ? Logger::Severity::DEBUG : Logger::Severity::INFO
|
33
|
+
end
|
32
34
|
|
33
|
-
|
34
|
-
#
|
35
|
-
#
|
36
|
-
message
|
37
|
-
|
35
|
+
# Add new entry to the work log.
|
36
|
+
# @param message [String] the message to add to the work log. This cannot be empty.
|
37
|
+
# @param options [Hash] the options hash containing date, time, tags, ticket, url, epic, and project.
|
38
|
+
# @raise [ArgumentError] if the message is empty.
|
39
|
+
#
|
40
|
+
# @example
|
41
|
+
# worklog.add('Worked on feature X', date: '2023-10-01', time: '10:00:00', tags: ['feature', 'x'], ticket:
|
42
|
+
# 'TICKET-123', url: 'https://example.com/', epic: true, project: 'my_project')
|
43
|
+
#
|
44
|
+
# @return [void]
|
45
|
+
def add(message, options = {})
|
46
|
+
# Remove leading and trailing whitespaces
|
47
|
+
# Raise an error if the message is empty
|
48
|
+
message = message.strip
|
49
|
+
raise ArgumentError, 'Message cannot be empty' if message.empty?
|
50
|
+
|
51
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
52
|
+
time = Time.strptime(options[:time], '%H:%M:%S')
|
53
|
+
@storage.create_file_skeleton(date)
|
54
|
+
|
55
|
+
# Validate that the project exists if provided
|
56
|
+
validate_projects!(options[:project]) if options[:project] && !options[:project].empty?
|
57
|
+
|
58
|
+
daily_log = @storage.load_log!(@storage.filepath(date))
|
59
|
+
new_entry = LogEntry.new(time:, tags: options[:tags], ticket: options[:ticket], url: options[:url],
|
60
|
+
epic: options[:epic], message:, project: options[:project])
|
61
|
+
daily_log.entries << new_entry
|
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
|
+
people_hash = @storage.load_people_hash
|
69
|
+
(new_entry.people - people_hash.keys).each do |handle|
|
70
|
+
WorkLogger.warn "Person with handle #{handle} not found. Consider adding them to people.yaml"
|
71
|
+
end
|
38
72
|
|
39
|
-
|
40
|
-
|
41
|
-
@storage.create_file_skeleton(date)
|
73
|
+
WorkLogger.info Rainbow("Added entry on #{options[:date]}: #{message}").green
|
74
|
+
end
|
42
75
|
|
43
|
-
|
44
|
-
|
45
|
-
epic: options[:epic], message:)
|
76
|
+
def edit(options = {})
|
77
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
46
78
|
|
47
|
-
|
48
|
-
|
79
|
+
# Load existing log
|
80
|
+
log = @storage.load_log(@storage.filepath(date))
|
81
|
+
unless log
|
82
|
+
WorkLogger.error "No work log found for #{options[:date]}. Aborting."
|
83
|
+
exit 1
|
84
|
+
end
|
49
85
|
|
50
|
-
|
86
|
+
txt = Editor::EDITOR_PREAMBLE.result_with_hash(content: YAML.dump(log))
|
87
|
+
return_val = Editor.open_editor(txt)
|
51
88
|
|
52
|
-
|
53
|
-
|
89
|
+
@storage.write_log(@storage.filepath(date),
|
90
|
+
YAML.load(return_val, permitted_classes: [Date, Time, DailyLog, LogEntry]))
|
91
|
+
WorkLogger.info Rainbow("Updated work log for #{options[:date]}").green
|
92
|
+
end
|
93
|
+
|
94
|
+
def show(options = {})
|
95
|
+
people = @storage.load_people!
|
96
|
+
printer = Printer.new(people)
|
54
97
|
|
55
|
-
|
56
|
-
date = Date.strptime(options[:date], '%Y-%m-%d')
|
98
|
+
start_date, end_date = start_end_date(options)
|
57
99
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
100
|
+
entries = @storage.days_between(start_date, end_date)
|
101
|
+
if entries.empty?
|
102
|
+
printer.no_entries(start_date, end_date)
|
103
|
+
else
|
104
|
+
entries.each do |entry|
|
105
|
+
printer.print_day(entry, entries.size > 1, options[:epics_only])
|
106
|
+
end
|
107
|
+
end
|
63
108
|
end
|
64
109
|
|
65
|
-
|
66
|
-
|
110
|
+
def people(person = nil, _options = {})
|
111
|
+
all_people = @storage.load_people!
|
112
|
+
people_map = all_people.to_h { |p| [p.handle, p] }
|
113
|
+
all_logs = @storage.all_days
|
67
114
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
115
|
+
if person
|
116
|
+
unless people_map.key?(person)
|
117
|
+
WorkLogger.error Rainbow("No person found with handle #{person}.").red
|
118
|
+
return
|
119
|
+
end
|
120
|
+
person_detail(all_logs, all_people, people_map[person.strip])
|
121
|
+
else
|
122
|
+
puts 'People mentioned in the work log:'
|
72
123
|
|
73
|
-
|
74
|
-
people = @storage.load_people!
|
75
|
-
printer = Printer.new(people)
|
124
|
+
mentions = {}
|
76
125
|
|
77
|
-
|
126
|
+
all_logs.map(&:people).each do |people|
|
127
|
+
mentions.merge!(people) { |_key, oldval, newval| oldval + newval }
|
128
|
+
end
|
78
129
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
130
|
+
# Sort the mentions by handle
|
131
|
+
mentions = mentions.to_a.sort_by { |handle, _| handle }
|
132
|
+
|
133
|
+
mentions.each do |handle, v|
|
134
|
+
if people_map.key?(handle)
|
135
|
+
person = people_map[handle]
|
136
|
+
print "#{Rainbow(person.name).gold} (#{handle})"
|
137
|
+
print " (#{person.team})" if person.team
|
138
|
+
else
|
139
|
+
print handle
|
140
|
+
end
|
141
|
+
puts ": #{v} #{pluralize(v, 'occurrence')}"
|
142
|
+
end
|
85
143
|
end
|
86
144
|
end
|
87
|
-
end
|
88
145
|
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
all_logs = @storage.all_days
|
146
|
+
def person_detail(all_logs, all_people, person)
|
147
|
+
printer = Printer.new(all_people)
|
148
|
+
puts "All interactions with #{Rainbow(person.name).gold}"
|
93
149
|
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
150
|
+
if person.notes
|
151
|
+
puts 'Notes:'
|
152
|
+
person.notes.each do |note|
|
153
|
+
puts "* #{note}"
|
154
|
+
end
|
98
155
|
end
|
99
|
-
person_detail(all_logs, all_people, people_map[person.strip])
|
100
|
-
else
|
101
|
-
puts 'People mentioned in the work log:'
|
102
|
-
|
103
|
-
mentions = {}
|
104
156
|
|
105
|
-
|
106
|
-
|
157
|
+
puts 'Interactions:'
|
158
|
+
all_logs.each do |daily_log|
|
159
|
+
daily_log.entries.each do |entry|
|
160
|
+
printer.print_entry(daily_log, entry, true) if entry.people.include?(person.handle)
|
161
|
+
end
|
107
162
|
end
|
163
|
+
end
|
108
164
|
|
109
|
-
|
110
|
-
|
165
|
+
def projects(_options = {})
|
166
|
+
project_storage = ProjectStorage.new(@config)
|
167
|
+
projects = project_storage.load_projects
|
168
|
+
puts Rainbow('Projects:').gold
|
169
|
+
projects.each_value do |project|
|
170
|
+
puts "#{Rainbow(project.name).gold} (#{project.key})"
|
171
|
+
puts " Description: #{project.description}" if project.description
|
172
|
+
puts " Start date: #{project.start_date}" if project.start_date
|
173
|
+
puts " End date: #{project.end_date}" if project.end_date
|
174
|
+
puts " Status: #{project.status}" if project.status
|
175
|
+
end
|
176
|
+
puts 'No projects found.' if projects.empty?
|
177
|
+
end
|
111
178
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
179
|
+
# Show all tags used in the work log or details for a specific tag
|
180
|
+
#
|
181
|
+
# @param tag [String, nil] the tag to show details for, or nil to show all tags
|
182
|
+
# @param options [Hash] the options hash containing date range
|
183
|
+
# @return [void]
|
184
|
+
#
|
185
|
+
# @example
|
186
|
+
# worklog.tags('example_tag', from: '2023-10-01', to: '2023-10-31')
|
187
|
+
# worklog.tags(nil) # Show all tags for all time
|
188
|
+
def tags(tag = nil, options = {})
|
189
|
+
if tag.nil? || tag.empty?
|
190
|
+
tag_overview
|
191
|
+
else
|
192
|
+
tag_detail(tag, options)
|
119
193
|
end
|
120
194
|
end
|
121
|
-
end
|
122
195
|
|
123
|
-
|
124
|
-
|
125
|
-
|
196
|
+
def tag_overview
|
197
|
+
all_logs = @storage.all_days
|
198
|
+
puts Rainbow('Tags used in the work log:').gold
|
126
199
|
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
200
|
+
# Count all tags used in the work log
|
201
|
+
tags = all_logs.map(&:entries).flatten.map(&:tags).flatten.compact.tally
|
202
|
+
|
203
|
+
# Determine length of longest tag for formatting
|
204
|
+
# Add one additonal space for formatting
|
205
|
+
max_len = tags.empty? ? 0 : tags.keys.map(&:length).max + 1
|
206
|
+
|
207
|
+
tags.sort.each { |k, v| puts "#{Rainbow(k.ljust(max_len)).gold}: #{v} #{pluralize(v, 'occurrence')}" }
|
132
208
|
end
|
133
209
|
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
210
|
+
# Show detailed information about a specific tag
|
211
|
+
#
|
212
|
+
# @param tag [String] the tag to show details for
|
213
|
+
# @param options [Hash] the options hash containing date range
|
214
|
+
# @return [void]
|
215
|
+
#
|
216
|
+
# @example
|
217
|
+
# worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')
|
218
|
+
def tag_detail(tag, options)
|
219
|
+
printer = Printer.new(@storage.load_people!)
|
220
|
+
start_date, end_date = start_end_date(options)
|
221
|
+
|
222
|
+
@storage.days_between(start_date, end_date).each do |daily_log|
|
223
|
+
next unless daily_log.tags.include?(tag)
|
224
|
+
|
225
|
+
daily_log.entries.each do |entry|
|
226
|
+
next unless entry.tags.include?(tag)
|
227
|
+
|
228
|
+
printer.print_entry(daily_log, entry, true)
|
229
|
+
end
|
138
230
|
end
|
139
231
|
end
|
140
|
-
end
|
141
232
|
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
# worklog.tags(nil) # Show all tags for all time
|
151
|
-
def tags(tag = nil, options = {})
|
152
|
-
if tag.nil? || tag.empty?
|
153
|
-
tag_overview
|
154
|
-
else
|
155
|
-
tag_detail(tag, options)
|
233
|
+
def stats(_options = {})
|
234
|
+
stats = Statistics.new(@config).calculate
|
235
|
+
puts "#{format_left('Total days')}: #{stats.total_days}"
|
236
|
+
puts "#{format_left('Total entries')}: #{stats.total_entries}"
|
237
|
+
puts "#{format_left('Total epics')}: #{stats.total_epics}"
|
238
|
+
puts "#{format_left('Entries per day')}: #{format('%.2f', stats.avg_entries)}"
|
239
|
+
puts "#{format_left('First entry')}: #{stats.first_entry}"
|
240
|
+
puts "#{format_left('Last entry')}: #{stats.last_entry}"
|
156
241
|
end
|
157
|
-
end
|
158
242
|
|
159
|
-
|
160
|
-
|
161
|
-
|
243
|
+
def summary(options = {})
|
244
|
+
start_date, end_date = start_end_date(options)
|
245
|
+
entries = @storage.days_between(start_date, end_date).map(&:entries).flatten
|
162
246
|
|
163
|
-
|
164
|
-
|
247
|
+
# Do nothing if no entries are found.
|
248
|
+
if entries.empty?
|
249
|
+
Printer.new.no_entries(start_date, end_date)
|
250
|
+
return
|
251
|
+
end
|
165
252
|
|
166
|
-
|
167
|
-
|
168
|
-
|
253
|
+
# List all the epics
|
254
|
+
epics = entries.filter(&:epic)
|
255
|
+
puts Rainbow("Found #{epics.size} epics.").green if epics.any?
|
256
|
+
epics.each do |entry|
|
257
|
+
puts "#{entry.time.strftime('%b %d, %Y')} #{entry.message}"
|
258
|
+
end
|
169
259
|
|
170
|
-
|
171
|
-
|
260
|
+
# List all the tags and their count
|
261
|
+
tags = entries.map(&:tags).flatten.compact.tally
|
262
|
+
puts Rainbow("Found #{tags.size} tags.").green if tags.any?
|
263
|
+
tags.each do |tag, count|
|
264
|
+
print "#{tag} (#{count}x), "
|
265
|
+
end
|
266
|
+
puts '' if tags.any?
|
172
267
|
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
#
|
179
|
-
# @example
|
180
|
-
# worklog.tag_detail('example_tag', from: '2023-10-01', to: '2023-10-31')
|
181
|
-
def tag_detail(tag, options)
|
182
|
-
printer = Printer.new(@storage.load_people!)
|
183
|
-
start_date, end_date = start_end_date(options)
|
184
|
-
|
185
|
-
@storage.days_between(start_date, end_date).each do |daily_log|
|
186
|
-
next unless daily_log.tags.include?(tag)
|
187
|
-
|
188
|
-
daily_log.entries.each do |entry|
|
189
|
-
next unless entry.tags.include?(tag)
|
190
|
-
|
191
|
-
printer.print_entry(daily_log, entry, true)
|
268
|
+
# List all the people and their count
|
269
|
+
people = entries.map(&:people).flatten.compact.tally.sort_by { |_, count| -count }.filter { |_, count| count > 1 }
|
270
|
+
puts Rainbow("Found #{people.size} people.").green if people.any?
|
271
|
+
people.each do |person, count|
|
272
|
+
print "#{person} (#{count}x), "
|
192
273
|
end
|
274
|
+
puts '' if people.any?
|
275
|
+
|
276
|
+
# # Print the summary
|
277
|
+
# summary = Summary.new(entries)
|
278
|
+
# puts summary.to_s
|
193
279
|
end
|
194
|
-
end
|
195
280
|
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
puts "#{format_left('First entry')}: #{stats.first_entry}"
|
203
|
-
puts "#{format_left('Last entry')}: #{stats.last_entry}"
|
204
|
-
end
|
281
|
+
def remove(options = {})
|
282
|
+
date = Date.strptime(options[:date], '%Y-%m-%d')
|
283
|
+
unless File.exist?(@storage.filepath(date))
|
284
|
+
WorkLogger.error Rainbow("No work log found for #{options[:date]}. Aborting.").red
|
285
|
+
exit 1
|
286
|
+
end
|
205
287
|
|
206
|
-
|
207
|
-
|
208
|
-
|
288
|
+
daily_log = @storage.load_log!(@storage.filepath(options[:date]))
|
289
|
+
if daily_log.entries.empty?
|
290
|
+
WorkLogger.error Rainbow("No entries found for #{options[:date]}. Aborting.").red
|
291
|
+
exit 1
|
292
|
+
end
|
209
293
|
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
return
|
294
|
+
removed_entry = daily_log.entries.pop
|
295
|
+
@storage.write_log(@storage.filepath(date), daily_log)
|
296
|
+
WorkLogger.info Rainbow("Removed entry: #{removed_entry.message}").green
|
214
297
|
end
|
215
|
-
puts Summary.new(@config).generate_summary(entries)
|
216
|
-
end
|
217
298
|
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
exit 1
|
299
|
+
# Start webserver
|
300
|
+
def server
|
301
|
+
app = WorkLogApp.new(@storage)
|
302
|
+
WorkLogServer.new(app).start
|
223
303
|
end
|
224
304
|
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
305
|
+
# Parse the start and end date based on the options provided
|
306
|
+
#
|
307
|
+
# @param options [Hash] the options hash
|
308
|
+
# @return [Array] the start and end date as an array
|
309
|
+
def start_end_date(options)
|
310
|
+
if options[:days]
|
311
|
+
# Safeguard against negative days
|
312
|
+
raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
|
313
|
+
|
314
|
+
start_date = Date.today - options[:days]
|
315
|
+
end_date = Date.today
|
316
|
+
elsif options[:from]
|
317
|
+
start_date = DateParser.parse_date_string!(options[:from], true)
|
318
|
+
end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
|
319
|
+
elsif options[:date]
|
320
|
+
start_date = Date.strptime(options[:date], '%Y-%m-%d')
|
321
|
+
end_date = start_date
|
322
|
+
else
|
323
|
+
raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
|
324
|
+
end
|
325
|
+
[start_date, end_date]
|
229
326
|
end
|
230
327
|
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
WorkLogServer.new(app).start
|
240
|
-
end
|
328
|
+
def validate_projects!(project_key)
|
329
|
+
project_storage = ProjectStorage.new(@config)
|
330
|
+
begin
|
331
|
+
projects = project_storage.load_projects
|
332
|
+
rescue Errno::ENOENT
|
333
|
+
raise ProjectNotFoundError, 'No projects found. Please create a project first.'
|
334
|
+
end
|
335
|
+
return if projects.key?(project_key)
|
241
336
|
|
242
|
-
|
243
|
-
#
|
244
|
-
# @param options [Hash] the options hash
|
245
|
-
# @return [Array] the start and end date as an array
|
246
|
-
def start_end_date(options)
|
247
|
-
if options[:days]
|
248
|
-
# Safeguard against negative days
|
249
|
-
raise ArgumentError, 'Number of days cannot be negative' if options[:days].negative?
|
250
|
-
|
251
|
-
start_date = Date.today - options[:days]
|
252
|
-
end_date = Date.today
|
253
|
-
elsif options[:from]
|
254
|
-
start_date = DateParser.parse_date_string!(options[:from], true)
|
255
|
-
end_date = DateParser.parse_date_string!(options[:to], false) if options[:to]
|
256
|
-
elsif options[:date]
|
257
|
-
start_date = Date.strptime(options[:date], '%Y-%m-%d')
|
258
|
-
end_date = start_date
|
259
|
-
else
|
260
|
-
raise ArgumentError, 'No date range specified. Use --days, --from, --to or --date options.'
|
337
|
+
raise ProjectNotFoundError, "Project with key '#{project_key}' does not exist."
|
261
338
|
end
|
262
|
-
[start_date, end_date]
|
263
339
|
end
|
264
340
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fewald-worklog
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.11
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Friedrich Ewald
|
@@ -131,6 +131,8 @@ files:
|
|
131
131
|
- lib/log_entry.rb
|
132
132
|
- lib/person.rb
|
133
133
|
- lib/printer.rb
|
134
|
+
- lib/project.rb
|
135
|
+
- lib/project_storage.rb
|
134
136
|
- lib/statistics.rb
|
135
137
|
- lib/storage.rb
|
136
138
|
- lib/string_helper.rb
|