fewald-worklog 0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dc96d7e3c9fa2cb64c09f04b13683f64dbce8b993d16e0529df92fddff947901
4
+ data.tar.gz: 0d4de596547f66de795f7de9ec979924b18d19bac9e5b4722f755306db85f8fd
5
+ SHA512:
6
+ metadata.gz: a48499cd05d20d472b4126e18b588114740c26a18cb7b9fe6eb10aaabff231654406a70cf467c3a5d175165b8b6a7c81749bed376a8f0ffebbf97d43e50a1e94
7
+ data.tar.gz: ef081d883a7f2ed48e7513690a2844235851cf90bd03792d49e74f0b574b42161e1f674a37fadded9a37c66b6f938719dfdda4666a94a7066b11de17da5b18ce
data/.version ADDED
@@ -0,0 +1 @@
1
+
data/bin/wl ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # This is the main entry point for the worklog CLI.
5
+
6
+ if ENV['WL_PATH']
7
+ # Import the worklog CLI from the path specified in the WL_PATH environment variable
8
+ # This is used during development to avoid having to rely on the order of the $PATH.
9
+ puts "Loading worklog from #{ENV['WL_PATH']}. This should only be used during development."
10
+ puts 'To use the installed worklog, unset the WL_PATH environment variable.'
11
+ require_relative File.join(ENV['WL_PATH'], 'lib', 'cli')
12
+ else
13
+ require_relative '../lib/cli'
14
+ end
15
+
16
+ WorklogCLI.start
data/lib/cli.rb ADDED
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Add the current directory to the load path
4
+ # curr_dir = File.expand_path(__dir__)
5
+ # $LOAD_PATH << curr_dir unless $LOAD_PATH.include?(curr_dir)
6
+
7
+ require 'thor'
8
+ require 'date'
9
+ require 'worklogger'
10
+
11
+ require 'worklog'
12
+ require 'date_parser'
13
+ require 'configuration'
14
+ require 'editor'
15
+ require 'printer'
16
+ require 'statistics'
17
+ require 'storage'
18
+ require 'string_helper'
19
+ require 'summary'
20
+ require 'version'
21
+ require 'webserver'
22
+
23
+ # CLI for the work log application
24
+ class WorklogCLI < Thor
25
+ attr_accessor :config, :storage
26
+
27
+ include StringHelper
28
+ class_option :verbose, type: :boolean, aliases: '-v', desc: 'Enable verbose output'
29
+
30
+ package_name 'Worklog'
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
+
39
+ def self.exit_on_failure?
40
+ true
41
+ end
42
+
43
+ desc 'add MESSAGE', 'Add a new entry to the work log, defaults to the current date.'
44
+ long_desc <<~LONGDESC
45
+ Add a new entry with the current date and time to the work log.
46
+ The message is required and must be enclosed in quotes if it contains more than one word.
47
+
48
+ People can be referenced either by using the tilde "~" or the at symbol "@", followed by
49
+ an alphanumeric string.
50
+ LONGDESC
51
+ option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'), desc: 'Set the date of the entry'
52
+ option :time, type: :string, default: DateTime.now.strftime('%H:%M:%S'), desc: 'Set the time of the entry'
53
+ option :tags, type: :array, default: [], desc: 'Add tags to the entry'
54
+ option :ticket, type: :string, desc: 'Ticket number associated with the entry. Can be any alphanumeric string.'
55
+ option :url, type: :string, desc: 'URL to associate with the entry'
56
+ option :epic, type: :boolean, default: false, desc: 'Mark the entry as an epic'
57
+ def add(message)
58
+ worklog = Worklog.new
59
+ worklog.add(message, options)
60
+ end
61
+
62
+ desc 'edit', 'Edit a day in the work log. By default, the current date is used.'
63
+ option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
64
+ def edit
65
+ worklog = Worklog.new
66
+ worklog.edit(options)
67
+ end
68
+
69
+ desc 'remove', 'Remove last entry from the log'
70
+ option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
71
+ def remove
72
+ worklog = Worklog.new
73
+ worklog.remove(options)
74
+ end
75
+
76
+ desc 'show', 'Show the work log for a specific date or a range of dates. Defaults to todays date.'
77
+ long_desc <<~LONGDESC
78
+ Show the work log for a specific date or a range of dates. As a default, all items from the current day will be shown.
79
+ LONGDESC
80
+ option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d'),
81
+ desc: <<~DESC
82
+ Show the work log for a specific date. If this option is provided, --from and --to and --days should not be used.
83
+ DESC
84
+ option :from, type: :string, desc: <<~EOF
85
+ Inclusive start date of the range. Takes precedence over --date, if defined.
86
+ EOF
87
+ option :to, type: :string, desc: <<~EOF
88
+ Inclusive end date of the range. Takes precedence over --date, if defined.
89
+ EOF
90
+ option :days, type: :numeric, desc: <<~EOF
91
+ Number of days to show starting from --date. Takes precedence over --from and --to if defined.
92
+ EOF
93
+ option :epics_only, type: :boolean, default: false, desc: 'Show only entries that are marked as epic'
94
+ option :tags, type: :array, default: [], desc: 'Filter entries by tags. Tags are treated as an OR condition.'
95
+ def show
96
+ worklog = Worklog.new
97
+ worklog.show(options)
98
+ end
99
+
100
+ desc 'people', 'Show all people mentioned in the work log'
101
+ def people(person = nil)
102
+ worklog = Worklog.new
103
+ worklog.people(person, options)
104
+ end
105
+
106
+ desc 'tags', 'Show all tags used in the work log'
107
+ def tags
108
+ worklog = Worklog.new
109
+ worklog.tags(options)
110
+ end
111
+
112
+ desc 'server', 'Start the work log server'
113
+ def server
114
+ app = WorkLogApp.new(@storage)
115
+ WorkLogServer.new(app).start
116
+ end
117
+
118
+ desc 'stats', 'Show statistics for the work log'
119
+ def stats
120
+ worklog = Worklog.new
121
+ worklog.stats(options)
122
+ end
123
+
124
+ desc 'summary', 'Generate a summary of the work log entries'
125
+ option :date, type: :string, default: DateTime.now.strftime('%Y-%m-%d')
126
+ option :from, type: :string, desc: <<-EOF
127
+ 'Inclusive start date of the range. Takes precedence over --date if defined.'
128
+ EOF
129
+ option :to, type: :string, desc: <<-EOF
130
+ 'Inclusive end date of the range. Takes precedence over --date if defined.'
131
+ EOF
132
+ option :days, type: :numeric, desc: <<-EOF
133
+ 'Number of days to show starting from --date. Takes precedence over --from and --to if defined.'
134
+ EOF
135
+ def summary
136
+ worklog = Worklog.new
137
+ worklog.summary(options)
138
+ end
139
+
140
+ desc 'version', 'Show the version of the Worklog'
141
+ def version
142
+ puts "Worklog #{current_version} running on Ruby #{RUBY_VERSION}"
143
+ end
144
+
145
+ # Define shortcuts and aliases
146
+ map 'a' => :add
147
+ map 'statistics' => :stats
148
+ map 'serve' => :server
149
+ end
@@ -0,0 +1,33 @@
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
+ # Load configuration from a YAML file.
20
+ # The file should be located at ~/.worklog.yaml.
21
+ def load_configuration
22
+ file_path = File.join(Dir.home, '.worklog.yaml')
23
+ if File.exist?(file_path)
24
+ file_cfg = YAML.load_file(file_path)
25
+ Configuration.new do |cfg|
26
+ cfg.storage_path = file_cfg['storage_path'] if file_cfg['storage_path']
27
+ cfg.log_level = file_cfg['log_level'].to_sym if file_cfg['log_level']
28
+ cfg.webserver_port = file_cfg['webserver_port'] if file_cfg['webserver_port']
29
+ end
30
+ else
31
+ puts "Configuration file does not exist in #{file_path}"
32
+ end
33
+ end
data/lib/daily_log.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'hash'
4
+
5
+ # DailyLog is a container for a day's work log.
6
+ class DailyLog
7
+ # Container for a day's work log.
8
+ include Hashify
9
+
10
+ # Represents a single day's work log.
11
+ attr_accessor :date, :entries
12
+
13
+ def initialize(params = {})
14
+ @date = params[:date]
15
+ @entries = params[:entries]
16
+ end
17
+
18
+ def people?
19
+ # Returns true if there are people mentioned in any entry of the current day.
20
+ people.size.positive?
21
+ end
22
+
23
+ def people
24
+ # Returns a hash of people mentioned in the log for the current day
25
+ # with the number of times they are mentioned.
26
+ # People are defined as words starting with @ or ~.
27
+ #
28
+ # @return [Hash<String, Integer>]
29
+ entries.map(&:people).flatten.tally
30
+ end
31
+
32
+ def ==(other)
33
+ date == other.date && entries == other.entries
34
+ end
35
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module DateParser
6
+ # Best effort date parsing from multiple formats.
7
+ def self.parse_date_string(date_str, from_beginning = true)
8
+ return nil if date_str.nil?
9
+ return nil if date_str.empty?
10
+ return nil if date_str.length > 10
11
+
12
+ # Try to parse basic format YYYY-MM-DD
13
+ begin
14
+ return Date.strptime(date_str, '%Y-%m-%d') if date_str.match(/^\d{4}-\d{1,2}-\d{1,2}$/)
15
+ rescue Date::Error
16
+ # puts "Date not in format YYYY-MM-DD."
17
+ end
18
+
19
+ # Try to parse format YYYY-MM
20
+ begin
21
+ if date_str.match(/^\d{4}-\d{1,2}$/)
22
+ d = Date.strptime(date_str, '%Y-%m')
23
+ return d if from_beginning
24
+
25
+ return Date.new(d.year, d.month, -1)
26
+
27
+ end
28
+ rescue Date::Error
29
+ # puts "Date not in format YYYY-MM."
30
+ end
31
+
32
+ # Try to parse format YYYY (without Q1234)
33
+ if date_str.match(/^\d{4}$/)
34
+ d = Date.strptime(date_str, '%Y')
35
+ return d if from_beginning
36
+
37
+ return Date.new(d.year, -1, -1)
38
+
39
+ end
40
+
41
+ # Long form quarter (2024-Q1, etc.)
42
+ match = date_str.match(/(\d{4})-[qQ]([1234])/)
43
+ if match
44
+ year, quarter = match.captures.map(&:to_i)
45
+ d = Date.new(year, ((quarter - 1) * 3) + 1, 1)
46
+ return d if from_beginning
47
+
48
+ return Date.new(d.year, d.month + 2, -1)
49
+
50
+ end
51
+
52
+ # Short form quarter
53
+ match = date_str.match(/[qQ]([1234])/)
54
+ return unless match
55
+
56
+ quarter = match.captures.first.to_i
57
+ d = Date.new(Date.today.year, ((quarter - 1) * 3) + 1, 1)
58
+ return d if from_beginning
59
+
60
+ Date.new(d.year, d.month + 2, -1)
61
+ end
62
+
63
+ def self.parse_date_string!(date_str, from_beginning = true)
64
+ date = parse_date_string(date_str, from_beginning)
65
+ raise ArgumentError, "Could not parse date string: \"#{date_str}\"" if date.nil?
66
+
67
+ date
68
+ end
69
+ end
data/lib/editor.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'tempfile'
5
+
6
+ # Editor to handle editing of log entries.
7
+ module Editor
8
+ EDITOR_PREAMBLE = ERB.new <<~README
9
+ # Edit the content below, then save the file and quit the editor.
10
+ # The update content will be saved. The content MUST be valid YAML
11
+ # in order for the application to be able to update the records.
12
+
13
+ <%= content %>
14
+ README
15
+
16
+ # Open text editor (currently ViM) with the initial text.
17
+ # Upon saving and exiting the editor, the updated text is returned.
18
+ # @param initial_text [String] The initial text to display in the editor.
19
+ # @return [String] The updated text.
20
+ def self.open_editor(initial_text)
21
+ file_handle = Tempfile.create
22
+ file_handle.write(initial_text)
23
+ file_handle.close
24
+
25
+ # Open the editor with the temporary file.
26
+ system('vim', file_handle.path)
27
+
28
+ updated_text = nil
29
+
30
+ # Read the updated text from the file.
31
+ File.open(file_handle.path, 'r') do |f|
32
+ updated_text = f.read
33
+ WorkLogger.debug("Updated text: #{updated_text}")
34
+ end
35
+ File.unlink(file_handle.path)
36
+ updated_text
37
+ end
38
+ end
data/lib/hash.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashify
4
+ def to_hash
5
+ hash = {}
6
+ instance_variables.each do |var|
7
+ value = instance_variable_get(var)
8
+ hash[var.to_s.delete('@')] = value
9
+ end
10
+ hash
11
+ end
12
+ end
13
+
14
+ class Hash
15
+ # Convert all keys to strings so that the YAML file can be read from different languages.
16
+ # This is a monkey patch to the Hash class.
17
+
18
+ def stringify_keys
19
+ # Convert all keys to strings.
20
+ # This is useful when the hash is serialized to YAML.
21
+ #
22
+ # @return [Hash] the hash with all keys converted to strings
23
+ map { |k, v| [k.to_s, v] }.to_h
24
+ end
25
+ end
data/lib/log_entry.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'rainbow'
5
+ require 'daily_log'
6
+ require 'hash'
7
+
8
+ # A single log entry.
9
+ class LogEntry
10
+ PERSON_REGEX = /\s[~@](\w+)/
11
+
12
+ include Hashify
13
+
14
+ # Represents a single entry in the work log.
15
+ attr_accessor :time, :tags, :ticket, :url, :epic, :message
16
+
17
+ def initialize(params = {})
18
+ @time = params[:time]
19
+ # If tags are nil, set to empty array.
20
+ # This is similar to the CLI default value.
21
+ @tags = params[:tags] || []
22
+ @ticket = params[:ticket]
23
+ @url = params[:url] || ''
24
+ @epic = params[:epic]
25
+ @message = params[:message]
26
+ end
27
+
28
+ # Returns true if the entry is an epic, false otherwise.
29
+ def epic?
30
+ @epic == true
31
+ end
32
+
33
+ # Returns the message string with formatting without the time.
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}/) 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
+
49
+ s = ''
50
+
51
+ s += if epic
52
+ Rainbow("[EPIC] #{msg}").bg(:white).fg(:black)
53
+ else
54
+ msg
55
+ end
56
+
57
+ s += " [#{Rainbow(@ticket).fg(:blue)}]" if @ticket
58
+
59
+ # Add tags in brackets if defined.
60
+ s += ' [' + @tags.map { |tag| "#{tag}" }.join(', ') + ']' if @tags && @tags.size > 0
61
+
62
+ # Add URL in brackets if defined.
63
+ s += " [#{@url}]" if @url && @url != ''
64
+
65
+ s
66
+ end
67
+
68
+ def people
69
+ # Return people that are mentioned in the entry.
70
+ # People are defined as words starting with @ or ~.
71
+ # Whitespaces are used to separate people.
72
+ # Punctuation is not considered.
73
+ # Empty array if no people are mentioned.
74
+ #
75
+ # @return [Array<String>]
76
+ @message.scan(PERSON_REGEX).flatten.uniq.sort
77
+ end
78
+
79
+ def people?
80
+ # Return true if there are people in the entry.
81
+ #
82
+ # @return [Boolean]
83
+ people.size.positive?
84
+ end
85
+
86
+ def to_yaml
87
+ to_hash.to_yaml
88
+ end
89
+
90
+ def ==(other)
91
+ time == other.time && tags == other.tags && ticket == other.ticket && url == other.url &&
92
+ epic == other.epic && message == other.message
93
+ end
94
+ end
data/lib/person.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Represents a person at work.
4
+ class Person
5
+ attr_reader :handle, :name, :email, :team, :notes
6
+
7
+ def initialize(handle, name, email, team, notes = [])
8
+ @handle = handle
9
+ @name = name
10
+ @email = email
11
+ @team = team
12
+ @notes = notes
13
+ end
14
+
15
+ def to_s
16
+ return "#{name} (~#{handle})" if @email.nil?
17
+
18
+ "#{name} (~#{handle}) <#{email}>"
19
+ end
20
+
21
+ def ==(other)
22
+ return false unless other.is_a?(Person)
23
+
24
+ handle == other.handle && name == other.name && email == other.email && team == other.team && notes == other.notes
25
+ end
26
+ end
data/lib/printer.rb ADDED
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rainbow'
4
+
5
+ # Printer for work log entries
6
+ class Printer
7
+ attr_reader :people
8
+
9
+ # Initializes the printer with a list of people.
10
+ # @param people [Array<Person>] An array of Person objects.
11
+ def initialize(people = nil)
12
+ @people = (people || []).to_h { |person| [person.handle, person] }
13
+ end
14
+
15
+ # Prints a whole day of work log entries.
16
+ # If date_inline is true, the date is printed inline with the time.
17
+ # If epics_only is true, only epic entries are printed.
18
+ def print_day(daily_log, date_inline = false, epics_only = false)
19
+ daily_log.date = Date.strptime(daily_log.date, '%Y-%m-%d') unless daily_log.date.respond_to?(:strftime)
20
+
21
+ date_string = daily_log.date.strftime('%a, %B %-d, %Y')
22
+ puts "Work log for #{Rainbow(date_string).gold}" unless date_inline
23
+
24
+ daily_log.entries.each do |entry|
25
+ next if epics_only && !entry.epic?
26
+
27
+ print_entry(daily_log, entry, date_inline)
28
+ end
29
+ end
30
+
31
+ # Print a message when no entries are found.
32
+ # @param start_date [Date]
33
+ # @param end_date [Date]
34
+ # @return [void]
35
+ def no_entries(start_date, end_date)
36
+ if start_date == end_date
37
+ date_string = start_date.strftime('%a, %B %-d, %Y')
38
+ puts "No entries found for #{Rainbow(date_string).gold}."
39
+ else
40
+ start_date_string = start_date.strftime('%a, %B %-d, %Y')
41
+ end_date_string = end_date.strftime('%a, %B %-d, %Y')
42
+ puts "No entries found between #{Rainbow(start_date_string).gold} and #{Rainbow(end_date_string).gold}."
43
+ end
44
+ end
45
+
46
+ # Prints a single entry, formats the date and time.
47
+ # @param daily_log [DailyLog]
48
+ # @param entry [LogEntry]
49
+ # @param date_inline [Boolean] If true, the date is printed inline with the time.
50
+ def print_entry(daily_log, entry, date_inline = false)
51
+ entry.time = DateTime.strptime(entry.time, '%H:%M:%S') unless entry.time.respond_to?(:strftime)
52
+
53
+ time_string = if date_inline
54
+ "#{daily_log.date.strftime('%a, %Y-%m-%d')} #{entry.time.strftime('%H:%M')}"
55
+ else
56
+ entry.time.strftime('%H:%M')
57
+ end
58
+
59
+ puts "#{Rainbow(time_string).gold} #{entry.message_string(@people)}"
60
+ end
61
+ end
data/lib/statistics.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'storage'
5
+
6
+ STATS = Data.define(:total_days, :total_entries, :total_epics, :avg_entries, :first_entry, :last_entry)
7
+
8
+ # Module for calculating statistics for the work log.
9
+ class Statistics
10
+ # Initialize the Statistics class.
11
+ def initialize(config)
12
+ @config = config
13
+ @storage = Storage.new(config)
14
+ end
15
+
16
+ # Calculate statistics for the work log for all days.
17
+ # @return [STATS] The statistics for the work log
18
+ def calculate
19
+ all_entries = @storage.all_days
20
+ return STATS.new(0, 0, 0, 0, Date.today, Date.today) if all_entries.empty?
21
+
22
+ total_days = all_entries.length
23
+ total_entries = all_entries.sum { |entry| entry.entries.length }
24
+ total_epics = all_entries.sum { |entry| entry.entries.select { |item| item.epic? }.length }
25
+ avg_entries = total_entries.to_f / total_days
26
+ min_day = all_entries.min_by { |entry| entry.date }.date
27
+ max_day = all_entries.max_by { |entry| entry.date }.date
28
+
29
+ STATS.new(
30
+ total_days,
31
+ total_entries,
32
+ total_epics,
33
+ avg_entries,
34
+ min_day,
35
+ max_day
36
+ )
37
+ end
38
+ end