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 +7 -0
- data/.version +1 -0
- data/bin/wl +16 -0
- data/lib/cli.rb +149 -0
- data/lib/configuration.rb +33 -0
- data/lib/daily_log.rb +35 -0
- data/lib/date_parser.rb +69 -0
- data/lib/editor.rb +38 -0
- data/lib/hash.rb +25 -0
- data/lib/log_entry.rb +94 -0
- data/lib/person.rb +26 -0
- data/lib/printer.rb +61 -0
- data/lib/statistics.rb +38 -0
- data/lib/storage.rb +172 -0
- data/lib/string_helper.rb +35 -0
- data/lib/summary.rb +78 -0
- data/lib/templates/favicon.svg.erb +7 -0
- data/lib/templates/index.html.erb +148 -0
- data/lib/version.rb +31 -0
- data/lib/webserver.rb +115 -0
- data/lib/worklog.rb +216 -0
- data/lib/worklogger.rb +26 -0
- metadata +171 -0
data/lib/storage.rb
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rainbow'
|
4
|
+
require 'daily_log'
|
5
|
+
require 'log_entry'
|
6
|
+
require 'worklogger'
|
7
|
+
require 'person'
|
8
|
+
|
9
|
+
# Handles storage of daily logs and people
|
10
|
+
class Storage
|
11
|
+
# LogNotFoundError is raised when a log file is not found
|
12
|
+
class LogNotFoundError < StandardError; end
|
13
|
+
|
14
|
+
FILE_SUFFIX = '.yaml'
|
15
|
+
|
16
|
+
def initialize(config)
|
17
|
+
@config = config
|
18
|
+
end
|
19
|
+
|
20
|
+
def folder_exists?
|
21
|
+
Dir.exist?(@config.storage_path)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Return all logs for all available days
|
25
|
+
# @return [Array<DailyLog>] List of all logs
|
26
|
+
def all_days
|
27
|
+
return [] unless folder_exists?
|
28
|
+
|
29
|
+
logs = []
|
30
|
+
Dir.glob(File.join(@config.storage_path, "*#{FILE_SUFFIX}")).map do |file|
|
31
|
+
next if file.end_with?('people.yaml')
|
32
|
+
|
33
|
+
logs << load_log(file)
|
34
|
+
end
|
35
|
+
|
36
|
+
logs
|
37
|
+
end
|
38
|
+
|
39
|
+
# Return all tags as a set
|
40
|
+
# @return [Set<String>] Set of all tags
|
41
|
+
def tags
|
42
|
+
logs = all_days
|
43
|
+
tags = Set[]
|
44
|
+
logs.each do |log|
|
45
|
+
log.entries.each do |entry|
|
46
|
+
next unless entry.tags
|
47
|
+
|
48
|
+
entry.tags.each do |tag|
|
49
|
+
tags << tag
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
tags
|
54
|
+
end
|
55
|
+
|
56
|
+
# Return days between start_date and end_date
|
57
|
+
# If end_date is nil, return logs from start_date to today
|
58
|
+
#
|
59
|
+
# @param [Date] start_date The start date, inclusive
|
60
|
+
# @param [Date] end_date The end date, inclusive
|
61
|
+
# @param [Boolean] epics_only If true, only return logs with epic entries
|
62
|
+
# @param [Array<String>] tags_filter If provided, only return logs with entries that have at least one of the tags
|
63
|
+
# @return [Array<DailyLog>] List of logs
|
64
|
+
def days_between(start_date, end_date = nil, epics_only = nil, tags_filter = nil)
|
65
|
+
return [] unless folder_exists?
|
66
|
+
|
67
|
+
logs = []
|
68
|
+
end_date = Date.today if end_date.nil?
|
69
|
+
|
70
|
+
return [] if start_date > end_date
|
71
|
+
|
72
|
+
while start_date <= end_date
|
73
|
+
if File.exist?(filepath(start_date))
|
74
|
+
tmp_logs = load_log!(filepath(start_date))
|
75
|
+
tmp_logs.entries.keep_if { |entry| entry.epic? } if epics_only
|
76
|
+
|
77
|
+
if tags_filter
|
78
|
+
# Safeguard against entries without any tags, not just empty array
|
79
|
+
tmp_logs.entries.keep_if { |entry| entry.tags && (entry.tags & tags_filter).size > 0 }
|
80
|
+
end
|
81
|
+
|
82
|
+
logs << tmp_logs if tmp_logs.entries.length > 0
|
83
|
+
end
|
84
|
+
|
85
|
+
start_date += 1
|
86
|
+
end
|
87
|
+
logs
|
88
|
+
end
|
89
|
+
|
90
|
+
# Create file for a new day if it does not exist
|
91
|
+
# @param [Date] date The date, used as the file name.
|
92
|
+
def create_file_skeleton(date)
|
93
|
+
create_folder
|
94
|
+
|
95
|
+
File.write(filepath(date), YAML.dump(DailyLog.new(date:, entries: []))) unless File.exist?(filepath(date))
|
96
|
+
end
|
97
|
+
|
98
|
+
def load_log(file)
|
99
|
+
load_log!(file)
|
100
|
+
rescue LogNotFoundError
|
101
|
+
WorkLogger.error "No work log found for #{file}. Aborting."
|
102
|
+
nil
|
103
|
+
end
|
104
|
+
|
105
|
+
def load_log!(file)
|
106
|
+
WorkLogger.debug "Loading file #{file}"
|
107
|
+
begin
|
108
|
+
log = YAML.load_file(file, permitted_classes: [Date, Time, DailyLog, LogEntry])
|
109
|
+
log.entries.each do |entry|
|
110
|
+
entry.time = Time.parse(entry.time) unless entry.time.respond_to?(:strftime)
|
111
|
+
end
|
112
|
+
log
|
113
|
+
rescue Errno::ENOENT
|
114
|
+
raise LogNotFoundError
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def write_log(file, daily_log)
|
119
|
+
create_folder
|
120
|
+
|
121
|
+
WorkLogger.debug "Writing to file #{file}"
|
122
|
+
|
123
|
+
File.open(file, 'w') do |f|
|
124
|
+
f.puts daily_log.to_yaml
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def load_single_log_file(file, headline = true)
|
129
|
+
daily_log = load_log!(file)
|
130
|
+
puts "Work log for #{Rainbow(daily_log.date).gold}:" if headline
|
131
|
+
daily_log.entries
|
132
|
+
end
|
133
|
+
|
134
|
+
def load_people
|
135
|
+
load_people!
|
136
|
+
rescue Errno::ENOENT
|
137
|
+
WorkLogger.info 'Unable to load people.'
|
138
|
+
[]
|
139
|
+
end
|
140
|
+
|
141
|
+
# Load all people from the people file
|
142
|
+
# @return [Array<Person>] List of people
|
143
|
+
def load_people!
|
144
|
+
people_file = File.join(@config.storage_path, 'people.yaml')
|
145
|
+
return [] unless File.exist?(people_file)
|
146
|
+
|
147
|
+
YAML.load_file(people_file, permitted_classes: [Person])
|
148
|
+
end
|
149
|
+
|
150
|
+
# Write people to the people file
|
151
|
+
# @param [Array<Person>] people List of people
|
152
|
+
def write_people!(people)
|
153
|
+
create_folder
|
154
|
+
|
155
|
+
people_file = File.join(@config.storage_path, 'people.yaml')
|
156
|
+
File.open(people_file, 'w') do |f|
|
157
|
+
f.puts people.to_yaml
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Create folder if not exists already.
|
162
|
+
def create_folder
|
163
|
+
Dir.mkdir(@config.storage_path) unless Dir.exist?(@config.storage_path)
|
164
|
+
end
|
165
|
+
|
166
|
+
# Construct filepath for a given date.
|
167
|
+
# @param [Date] date The date
|
168
|
+
# @return [String] The filepath
|
169
|
+
def filepath(date)
|
170
|
+
File.join(@config.storage_path, "#{date}#{FILE_SUFFIX}")
|
171
|
+
end
|
172
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Helpers for String manipulation
|
4
|
+
module StringHelper
|
5
|
+
# Pluralize a word based on a count. If the plural form is irregular, it can be provided.
|
6
|
+
# Otherwise, it will be generated automatically.
|
7
|
+
#
|
8
|
+
# @param count [Integer] the count to base the pluralization on
|
9
|
+
# @param singular [String] the singular form of the word
|
10
|
+
# @param plural [String] the plural form of the word, if it is irregular. Otherwise it will be generated.
|
11
|
+
# @return [String] the pluralized word
|
12
|
+
def pluralize(count, singular, plural = nil)
|
13
|
+
if count == 1
|
14
|
+
singular
|
15
|
+
else
|
16
|
+
return plural if plural
|
17
|
+
|
18
|
+
return "#{singular[0..-2]}ies" if singular.end_with? 'y'
|
19
|
+
|
20
|
+
return "#{singular}es" if singular.end_with? 'ch', 's', 'sh', 'x', 'z'
|
21
|
+
|
22
|
+
return "#{singular[0..-2]}ves" if singular.end_with? 'f', 'fe'
|
23
|
+
|
24
|
+
"#{singular}s"
|
25
|
+
end
|
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
|
35
|
+
end
|
data/lib/summary.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'erb'
|
4
|
+
require 'httparty'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
require 'worklogger'
|
8
|
+
|
9
|
+
# AI Summary generation.
|
10
|
+
class Summary
|
11
|
+
MODEL = 'llama3.2'
|
12
|
+
SUMMARY_INSTRUCTION = <<~INSTRUCTION
|
13
|
+
<% entries.each do |entry| -%>
|
14
|
+
<%= entry.message %>
|
15
|
+
<% end -%>
|
16
|
+
INSTRUCTION
|
17
|
+
|
18
|
+
SYSTEM_INSTRUCTION = <<~INSTRUCTION
|
19
|
+
You are a professional summarization assistant specialized in crafting performance review summaries. Your role is to take a list of achievements provided by the user and generate a concise, professional summary suitable for use in a formal performance review.
|
20
|
+
|
21
|
+
Guidelines:
|
22
|
+
|
23
|
+
Accuracy: Do not invent or infer any facts not explicitly provided by the user. Use only the information given.
|
24
|
+
Tone: Maintain a formal, professional tone throughout the summary.
|
25
|
+
Structure: Organize the summary in a coherent manner, emphasizing key accomplishments and their impact.
|
26
|
+
Clarity: Use clear and concise language, avoiding jargon unless specified by the user.
|
27
|
+
Your Task:
|
28
|
+
|
29
|
+
Analyze the list of achievements provided by the user.
|
30
|
+
Identify the key themes and accomplishments.
|
31
|
+
Draft a polished summary that highlights the individual’s contributions and results.
|
32
|
+
Constraints:
|
33
|
+
|
34
|
+
Do not fabricate details or add context that has not been explicitly stated.
|
35
|
+
Always prioritize clarity and professionalism in your writing.
|
36
|
+
Example Input:
|
37
|
+
|
38
|
+
"Exceeded sales targets by 15% in Q3."
|
39
|
+
"Implemented a new CRM system, reducing customer response time by 30%."
|
40
|
+
"Mentored two junior team members, both of whom received promotions."
|
41
|
+
Example Output: "[Name] demonstrated outstanding performance during the review period. Key accomplishments include exceeding sales targets by 15% in Q3, implementing a new CRM system that improved customer response times by 30%, and mentoring two junior team members who achieved career advancements. These achievements highlight [Name]'s exceptional contributions to team success and organizational growth."
|
42
|
+
INSTRUCTION
|
43
|
+
|
44
|
+
def initialize(config)
|
45
|
+
@config = config
|
46
|
+
end
|
47
|
+
|
48
|
+
# Build the prompt from provided log entries.
|
49
|
+
def build_prompt(log_entries)
|
50
|
+
ERB.new(SUMMARY_INSTRUCTION, trim_mode: '-').result_with_hash(entries: log_entries)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Generate a summary from provided log entries.
|
54
|
+
# @param log_entries [Array<LogEntry>] The log entries to summarize.
|
55
|
+
# @return [String] The generated summary.
|
56
|
+
def generate_summary(log_entries)
|
57
|
+
prompt = build_prompt(log_entries)
|
58
|
+
|
59
|
+
WorkLogger.debug("Using prompt: #{prompt}")
|
60
|
+
|
61
|
+
begin
|
62
|
+
response = HTTParty.post('http://localhost:11434/api/generate',
|
63
|
+
body: {
|
64
|
+
model: Summary::MODEL,
|
65
|
+
prompt:,
|
66
|
+
system: Summary::SYSTEM_INSTRUCTION,
|
67
|
+
stream: false
|
68
|
+
}.to_json,
|
69
|
+
headers: { 'Content-Type' => 'application/json' })
|
70
|
+
response.parsed_response['response']
|
71
|
+
rescue Errno::ECONNREFUSED
|
72
|
+
WorkLogger.error <<~MSG
|
73
|
+
Ollama doesn't seem to be running. Please start the server and try again.
|
74
|
+
puts 'You can download Ollama at https://ollama.com'
|
75
|
+
MSG
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,7 @@
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
2
|
+
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
3
|
+
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
4
|
+
<rect fill="#fff" stroke="#000" x="0" y="0" width="128" height="128" stroke-width="4" />
|
5
|
+
<rect fill="#fff" stroke="#000" x="32" y="32" width="64" height="64" stroke-width="2" />
|
6
|
+
<text x="10" y="30" fill="green" font-size="32"><%= Date.today %></text>
|
7
|
+
</svg>
|
@@ -0,0 +1,148 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<title>Work log</title>
|
4
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
5
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
6
|
+
<link rel="icon" href="favicon.svg">
|
7
|
+
<link rel="mask-icon" href="favicon.svg" color="#000000">
|
8
|
+
<style type="text/css">
|
9
|
+
|
10
|
+
ul {
|
11
|
+
list-style-type: none;
|
12
|
+
padding: 0 1rem;
|
13
|
+
}
|
14
|
+
|
15
|
+
/* Special style for presentation mode */
|
16
|
+
.presentation {
|
17
|
+
.day {
|
18
|
+
font-size: 2rem;
|
19
|
+
line-height: 2.6rem;
|
20
|
+
|
21
|
+
ul {
|
22
|
+
border-bottom: 2px solid #AAA;
|
23
|
+
li {
|
24
|
+
padding-bottom: 1rem;
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
.entries {
|
29
|
+
display: none;
|
30
|
+
}
|
31
|
+
}
|
32
|
+
}
|
33
|
+
</style>
|
34
|
+
</head>
|
35
|
+
<body>
|
36
|
+
<div class="container <%= presentation ? 'presentation' : '' %>">
|
37
|
+
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
38
|
+
<div class="container-fluid">
|
39
|
+
<a class="navbar-brand" href="/">
|
40
|
+
<% if tags %>
|
41
|
+
<%= tags.first.capitalize %> items
|
42
|
+
<% else %>
|
43
|
+
Work log
|
44
|
+
<% end %>
|
45
|
+
</a>
|
46
|
+
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
47
|
+
<span class="navbar-toggler-icon"></span>
|
48
|
+
</button>
|
49
|
+
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
50
|
+
<div class="navbar-nav me-auto"></div>
|
51
|
+
<div class="d-flex">
|
52
|
+
<% if presentation %>
|
53
|
+
<a href="<%= update_query({'presentation' => nil}) %>" class="btn btn-primary">
|
54
|
+
Screen
|
55
|
+
</a>
|
56
|
+
<% else %>
|
57
|
+
<a href="<%= update_query({'presentation' => true}) %>" class="btn btn-primary">
|
58
|
+
Presentation
|
59
|
+
</a>
|
60
|
+
<% end %>
|
61
|
+
</div>
|
62
|
+
</div>
|
63
|
+
</div>
|
64
|
+
|
65
|
+
|
66
|
+
</nav>
|
67
|
+
<hr class="border border-primary border-2 opacity-75">
|
68
|
+
<div class="pb-4">
|
69
|
+
Show
|
70
|
+
<div class="dropdown d-inline">
|
71
|
+
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
72
|
+
<% if epics_only %>
|
73
|
+
only epics
|
74
|
+
<% else %>
|
75
|
+
all items
|
76
|
+
<% end %>
|
77
|
+
</a>
|
78
|
+
<ul class="dropdown-menu">
|
79
|
+
<li><a class="dropdown-item" href="<%= update_query({'epics_only' => false}) %>">all items</a></li>
|
80
|
+
<li><a class="dropdown-item" href="<%= update_query({'epics_only' => true}) %>">only epics</a></li>
|
81
|
+
</ul>
|
82
|
+
</div>
|
83
|
+
from the last
|
84
|
+
<div class="dropdown d-inline">
|
85
|
+
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
86
|
+
<%= days %> days
|
87
|
+
</a>
|
88
|
+
<ul class="dropdown-menu">
|
89
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 7}) %>">7 days</a></li>
|
90
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 14}) %>">2 weeks</a></li>
|
91
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 21}) %>">3 weeks</a></li>
|
92
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 28}) %>">4 weeks</a></li>
|
93
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 60}) %>">2 months</a></li>
|
94
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 90}) %>">3 months</a></li>
|
95
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 180}) %>">6 months</a></li>
|
96
|
+
<li><a class="dropdown-item" href="<%= update_query({'days' => 365}) %>">1 year</a></li>
|
97
|
+
</ul>
|
98
|
+
</div>
|
99
|
+
of work with
|
100
|
+
<div class="dropdown d-inline">
|
101
|
+
<a class="btn border-secondary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
102
|
+
<% if tags %>
|
103
|
+
<%= tags.size > 1 ? 'multiple tags' : "#{tags.first} tags" %>
|
104
|
+
<% else %>
|
105
|
+
all tags
|
106
|
+
<% end %>
|
107
|
+
</a>
|
108
|
+
<ul class="dropdown-menu">
|
109
|
+
<li><a class="dropdown-item" href="<%= update_query({'tags' => nil}) %>">all tags</a></li>
|
110
|
+
<% @tags.to_a.each do |tag| %>
|
111
|
+
<li><a class="dropdown-item" href="<%= update_query({'tags' => [tag]}) %>"><%= tag %> tags</a></li>
|
112
|
+
<% end %>
|
113
|
+
</ul>
|
114
|
+
</div>
|
115
|
+
.
|
116
|
+
</div>
|
117
|
+
|
118
|
+
<%- logs.each do |log| -%>
|
119
|
+
<section class="day">
|
120
|
+
<strong><%= log.date.strftime('%a, %B %-d, %Y') %></strong>
|
121
|
+
|
122
|
+
<ul>
|
123
|
+
<%- log.entries.each do |entry| -%>
|
124
|
+
<li>
|
125
|
+
<code><%= entry.time.strftime('%H:%M') %></code>
|
126
|
+
<% if entry.epic %>
|
127
|
+
<span class="badge text-bg-warning">EPIC</span>
|
128
|
+
<% end%>
|
129
|
+
<%= entry.message %>
|
130
|
+
<% if entry.tags and entry.tags.size > 0 %>
|
131
|
+
<% entry.tags.each do |tag| %>
|
132
|
+
<strong class="badge text-bg-secondary"><%= tag %></strong>
|
133
|
+
<% end %>
|
134
|
+
<% end %>
|
135
|
+
</li>
|
136
|
+
<%- end %>
|
137
|
+
</ul>
|
138
|
+
<p class="entries"><%= log.entries.size %> entries</p>
|
139
|
+
</section>
|
140
|
+
<%- end %>
|
141
|
+
<p><%= total_entries %> entries total</p>
|
142
|
+
</div>
|
143
|
+
<hr/>
|
144
|
+
<footer class="container pb-4 text-muted">
|
145
|
+
Generated at <%= Time.now.strftime('%Y-%m-%d %H:%M %Z') %>
|
146
|
+
</footer>
|
147
|
+
</body>
|
148
|
+
</html>
|
data/lib/version.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'pathname'
|
4
|
+
|
5
|
+
# Returns the current version of the gem from `.version`.
|
6
|
+
# Versioning follows SemVer.
|
7
|
+
# @return [String] The current version of the gem.
|
8
|
+
def current_version
|
9
|
+
version_file_path = File.join(Pathname.new(__dir__).parent, '.version')
|
10
|
+
File.read(version_file_path).strip
|
11
|
+
end
|
12
|
+
|
13
|
+
# Increment version number according to SemVer.
|
14
|
+
# @param version [String] The current version.
|
15
|
+
# @param part [String] The part of the version to increment.
|
16
|
+
# @return [String] The incremented version.
|
17
|
+
def increment_version(version, part = 'patch')
|
18
|
+
major, minor, patch = version.split('.').map(&:to_i)
|
19
|
+
case part
|
20
|
+
when 'major'
|
21
|
+
major += 1
|
22
|
+
minor = 0
|
23
|
+
patch = 0
|
24
|
+
when 'minor'
|
25
|
+
minor += 1
|
26
|
+
patch = 0
|
27
|
+
when 'patch'
|
28
|
+
patch += 1
|
29
|
+
end
|
30
|
+
[major, minor, patch].join('.')
|
31
|
+
end
|
data/lib/webserver.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
require 'erb'
|
5
|
+
require 'rack'
|
6
|
+
require 'rack/constants'
|
7
|
+
require 'rackup'
|
8
|
+
require 'uri'
|
9
|
+
require 'storage'
|
10
|
+
require 'worklog'
|
11
|
+
|
12
|
+
class DefaultHeaderMiddleware
|
13
|
+
# Rack middleware to add default headers to the response.
|
14
|
+
|
15
|
+
def initialize(app)
|
16
|
+
@app = app
|
17
|
+
end
|
18
|
+
|
19
|
+
def call(env)
|
20
|
+
status, headers, body = @app.call(env)
|
21
|
+
headers[Rack::CONTENT_TYPE] ||= 'text/html'
|
22
|
+
headers[Rack::CACHE_CONTROL] ||= 'no-cache'
|
23
|
+
[status, headers, body]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Class to render the main page of the WorkLog web application.
|
28
|
+
class WorkLogResponse
|
29
|
+
def initialize(storage, tags)
|
30
|
+
@storage = storage
|
31
|
+
@tags = tags
|
32
|
+
end
|
33
|
+
|
34
|
+
def response(request)
|
35
|
+
template = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'index.html.erb')), trim_mode: '-')
|
36
|
+
@params = request.params
|
37
|
+
days = @params['days'].nil? ? 7 : @params['days'].to_i
|
38
|
+
tags = @params['tags'].nil? ? nil : @params['tags'].split(',')
|
39
|
+
epics_only = @params['epics_only'] == 'true'
|
40
|
+
presentation = @params['presentation'] == 'true'
|
41
|
+
logs = @storage.days_between(Date.today - days, Date.today, epics_only, tags).reverse
|
42
|
+
total_entries = logs.sum { |entry| entry.entries.length }
|
43
|
+
_ = total_entries
|
44
|
+
_ = presentation
|
45
|
+
|
46
|
+
[200, {}, [template.result(binding)]]
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def update_query(new_params)
|
52
|
+
uri = URI.parse('/')
|
53
|
+
cloned = @params.clone
|
54
|
+
new_params.each do |key, value|
|
55
|
+
cloned[key] = value
|
56
|
+
end
|
57
|
+
uri.query = URI.encode_www_form(cloned)
|
58
|
+
uri
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_uri(params)
|
62
|
+
uri = URI.parse('/')
|
63
|
+
uri.query = URI.encode_www_form(params)
|
64
|
+
uri.to_s
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
class WorkLogApp
|
69
|
+
def initialize(storage)
|
70
|
+
@storage = storage
|
71
|
+
@tags = @storage.tags
|
72
|
+
end
|
73
|
+
|
74
|
+
def call(env)
|
75
|
+
req = Rack::Request.new(env)
|
76
|
+
WorkLogResponse.new(@storage, @tags).response(req)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# class FaviconApp
|
81
|
+
# # Rack application that creates a favicon.
|
82
|
+
|
83
|
+
# def self.call(_env)
|
84
|
+
# content = ERB.new(File.read(File.join(File.dirname(__FILE__), 'templates', 'favicon.svg.erb')))
|
85
|
+
# [200, { Rack::CONTENT_TYPE => 'image/svg+xml' }, [content.result]]
|
86
|
+
# end
|
87
|
+
# end
|
88
|
+
|
89
|
+
class WorkLogServer
|
90
|
+
# Main Rack server containing all endpoints.
|
91
|
+
def initialize(worklog_app)
|
92
|
+
@worklog_app = worklog_app
|
93
|
+
end
|
94
|
+
|
95
|
+
def start
|
96
|
+
worklog_app = @worklog_app
|
97
|
+
app = Rack::Builder.new do
|
98
|
+
use Rack::Deflater
|
99
|
+
use Rack::CommonLogger
|
100
|
+
use Rack::ShowExceptions
|
101
|
+
use Rack::ShowStatus
|
102
|
+
use DefaultHeaderMiddleware
|
103
|
+
|
104
|
+
map '/' do
|
105
|
+
run worklog_app
|
106
|
+
end
|
107
|
+
# TODO: Future development
|
108
|
+
# map '/favicon.svg' do
|
109
|
+
# run FaviconApp
|
110
|
+
# end
|
111
|
+
end
|
112
|
+
|
113
|
+
Rackup::Server.start app: app
|
114
|
+
end
|
115
|
+
end
|