ptimelog 0.9.0 → 0.12.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 +4 -4
- data/AGENTS.md +42 -0
- data/PLAN-ADD-COMMAND.md +140 -0
- data/README.md +117 -12
- data/exe/ptimelog +26 -2
- data/lib/ptimelog/command/add.rb +50 -11
- data/lib/ptimelog/command/confirm.rb +82 -0
- data/lib/ptimelog/command/edit.rb +47 -7
- data/lib/ptimelog/command/info.rb +48 -0
- data/lib/ptimelog/command/show.rb +46 -26
- data/lib/ptimelog/command/upload.rb +49 -18
- data/lib/ptimelog/configuration.rb +20 -10
- data/lib/ptimelog/data_source.rb +43 -0
- data/lib/ptimelog/day.rb +9 -34
- data/lib/ptimelog/entry.rb +92 -25
- data/lib/ptimelog/error.rb +7 -0
- data/lib/ptimelog/joiner.rb +47 -0
- data/lib/ptimelog/named_date.rb +26 -4
- data/lib/ptimelog/operon.rb +170 -0
- data/lib/ptimelog/script.rb +1 -1
- data/lib/ptimelog/timelog.rb +66 -1
- data/lib/ptimelog/version.rb +9 -1
- data/lib/ptimelog.rb +14 -3
- data/opencode.json +37 -0
- metadata +136 -47
- data/.envrc +0 -1
- data/.gitignore +0 -10
- data/.overcommit.yml +0 -26
- data/.rspec +0 -2
- data/.rubocop.yml +0 -33
- data/.rubocop_todo.yml +0 -16
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/.travis.yml +0 -7
- data/bin/console +0 -15
- data/bin/setup +0 -8
- data/lib/ptimelog/app.rb +0 -21
- data/lib/ptimelog/command/base.rb +0 -29
- data/lib/ptimelog/command/version.rb +0 -16
- data/ptimelog.gemspec +0 -34
data/lib/ptimelog/named_date.rb
CHANGED
|
@@ -9,17 +9,39 @@ module Ptimelog
|
|
|
9
9
|
named_date(arg) || :all
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def named_date(date)
|
|
12
|
+
def named_date(date) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/AbcSize
|
|
13
13
|
case date.to_s
|
|
14
|
-
when 'yesterday' then
|
|
14
|
+
when 'yesterday' then yesterday
|
|
15
15
|
when 'today' then Date.today.to_s
|
|
16
|
-
when 'last', '' then
|
|
17
|
-
when
|
|
16
|
+
when 'last', '' then (last_entry || yesterday).to_s
|
|
17
|
+
when 'mon', 'monday' then previous_weekday('monday')
|
|
18
|
+
when 'tue', 'tuesday' then previous_weekday('tuesday')
|
|
19
|
+
when 'wed', 'wednesday' then previous_weekday('wednesday')
|
|
20
|
+
when 'thu', 'thursday' then previous_weekday('thursday')
|
|
21
|
+
when 'fri', 'friday' then previous_weekday('friday')
|
|
22
|
+
when 'sat', 'saturday' then previous_weekday('saturday')
|
|
23
|
+
when 'sun', 'sunday' then previous_weekday('sunday')
|
|
24
|
+
when /\d{4}(-\d{2}){2}/ then date.to_s
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
|
|
21
28
|
private
|
|
22
29
|
|
|
30
|
+
def previous_weekday(date)
|
|
31
|
+
Date.today.prev_day(7)
|
|
32
|
+
.step(Date.today.prev_day)
|
|
33
|
+
.find { |d| d.send(:"#{date}?") }
|
|
34
|
+
.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def last_entry
|
|
38
|
+
timelog.to_h.keys.compact.sort[-2]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def yesterday
|
|
42
|
+
Date.today.prev_day.to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
23
45
|
def timelog
|
|
24
46
|
Timelog.load
|
|
25
47
|
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module Ptimelog
|
|
8
|
+
# Extract entries from the time-sessions Operon tracks on its tasks
|
|
9
|
+
class Operon # rubocop:disable Metrics/ClassLength
|
|
10
|
+
# Operon documents its own task-syntax as examples, and the archive is history
|
|
11
|
+
SKIP_PATHS = ['Operon/Docs', 'archive'].freeze
|
|
12
|
+
|
|
13
|
+
FIELD_RE = /\{\{(\w+)::\s*(.*?)\}\}/
|
|
14
|
+
TASK_RE = /^\s*-\s\[( |x|X)\]\s/
|
|
15
|
+
RANGE_RE = %r{
|
|
16
|
+
\A(?<start>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?)/
|
|
17
|
+
(?<stop>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?)\z
|
|
18
|
+
}x
|
|
19
|
+
|
|
20
|
+
# A config naming only one key must not lose the other
|
|
21
|
+
# TODO: determine if this can be done in the Ptimelog::Configuration
|
|
22
|
+
def self.custom_keys
|
|
23
|
+
Ptimelog::Configuration::CONFIGURATION_DEFAULTS['operon_custom_keys']
|
|
24
|
+
.merge(Ptimelog::Configuration.instance['operon_custom_keys'].to_h)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(day)
|
|
28
|
+
@day = day
|
|
29
|
+
@configuration = Ptimelog::Configuration.instance
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A missing vault would silently look like a day without any tracked time
|
|
33
|
+
class VaultNotFound < Ptimelog::Error
|
|
34
|
+
def initialize(path)
|
|
35
|
+
super(<<~MSG)
|
|
36
|
+
Could not find your Obsidian vault at #{path}
|
|
37
|
+
|
|
38
|
+
Please check 'obsidian_vault' in your ptimelog-config. It should
|
|
39
|
+
point to the folder which contains your '.obsidian'-directory.
|
|
40
|
+
MSG
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A session belongs to the day it started on. Plans and running timers are no time yet
|
|
45
|
+
def entries
|
|
46
|
+
tasks.flat_map { |task| entries_of(task) }
|
|
47
|
+
.sort_by(&:start_time)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# What a new entry would continue from: a running timer, the last session, the start of the day
|
|
51
|
+
def previous_entry
|
|
52
|
+
start = running_timer || entries.last&.finish_time || @configuration['operon_start_time'] || '08:00'
|
|
53
|
+
|
|
54
|
+
Ptimelog::Entry.new.tap do |entry|
|
|
55
|
+
entry.date = @day
|
|
56
|
+
entry.description = 'start **'
|
|
57
|
+
entry.start_time = start
|
|
58
|
+
entry.finish_time = start
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# An entry can come from any note of the vault, so there is no one file to point at
|
|
63
|
+
def file = NullPathname.new
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def vault
|
|
68
|
+
@vault ||= begin
|
|
69
|
+
path = @configuration['obsidian_vault']
|
|
70
|
+
raise VaultNotFound, path unless File.directory?(path)
|
|
71
|
+
|
|
72
|
+
path
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def markdown_files
|
|
77
|
+
Dir.glob(vault.join('**/*.md')).reject { |fn| ignored?(fn) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ignored?(fn)
|
|
81
|
+
relative = fn.delete_prefix("#{vault}/")
|
|
82
|
+
|
|
83
|
+
SKIP_PATHS.any? { |path| relative.start_with?("#{path}/") }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Every task of the vault, an inline one on its line, a file task in its frontmatter
|
|
87
|
+
def tasks
|
|
88
|
+
markdown_files.flat_map do |fn|
|
|
89
|
+
frontmatter, body = split_frontmatter(File.read(fn, encoding: 'UTF-8'))
|
|
90
|
+
|
|
91
|
+
file_tasks(frontmatter, fn) + inline_tasks(body)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def file_tasks(frontmatter, fn)
|
|
96
|
+
return [] unless frontmatter.is_a?(Hash)
|
|
97
|
+
|
|
98
|
+
[{ fields: frontmatter, description: File.basename(fn, '.md') }]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def inline_tasks(body)
|
|
102
|
+
body.each_line.filter_map do |line|
|
|
103
|
+
next unless line.match?(TASK_RE)
|
|
104
|
+
|
|
105
|
+
{ fields: fields_of(line), description: description_of(line) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fields_of(line) = line.scan(FIELD_RE).to_h.transform_values(&:strip)
|
|
110
|
+
|
|
111
|
+
def description_of(line)
|
|
112
|
+
line.sub(TASK_RE, '').sub(/\s*\{\{.*\}\}\s*\z/m, '').strip
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A file task keeps its fields in frontmatter, an inline task carries them on its line
|
|
116
|
+
def split_frontmatter(text)
|
|
117
|
+
return [nil, text] unless text.start_with?('---')
|
|
118
|
+
|
|
119
|
+
close = text.index("\n---", 3)
|
|
120
|
+
return [nil, text] unless close
|
|
121
|
+
|
|
122
|
+
[YAML.safe_load(text[4...close], permitted_classes: [Date, Time], aliases: true), text[(close + 4)..]]
|
|
123
|
+
rescue Psych::Exception
|
|
124
|
+
[nil, text]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def entries_of(task)
|
|
128
|
+
session_list(task[:fields]['trackers']).filter_map do |session|
|
|
129
|
+
next unless on_day?(session[:start])
|
|
130
|
+
|
|
131
|
+
entry_for(session, task)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Malformed ranges are dropped, one broken note must not sink a whole day
|
|
136
|
+
def session_list(sessions)
|
|
137
|
+
list = sessions.is_a?(String) ? sessions.split('; ') : Array(sessions)
|
|
138
|
+
|
|
139
|
+
list.filter_map { |session| RANGE_RE.match(session.to_s.strip) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A running timer is no session yet, but it says where the work stopped
|
|
143
|
+
def running_timer
|
|
144
|
+
started = tasks.filter_map { |task| task[:fields]['activeTracker'] }.find { |at| on_day?(at) }
|
|
145
|
+
|
|
146
|
+
started && Time.parse(started).strftime('%R')
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def on_day?(datetime)
|
|
150
|
+
Time.parse(datetime).strftime('%F') == @day
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def entry_for(session, task) # rubocop:disable Metrics/AbcSize
|
|
154
|
+
fields = task[:fields]
|
|
155
|
+
|
|
156
|
+
Ptimelog::Entry.new.tap do |entry|
|
|
157
|
+
entry.date = @day
|
|
158
|
+
entry.ticket = fields[custom_key('ticket')]
|
|
159
|
+
entry.description = task[:description]
|
|
160
|
+
entry.tags = fields[custom_key('inferer')]
|
|
161
|
+
entry.start_time = Time.parse(session[:start]).strftime('%R')
|
|
162
|
+
entry.finish_time = Time.parse(session[:stop]).strftime('%R')
|
|
163
|
+
|
|
164
|
+
entry.infer_ptime_settings
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def custom_key(name) = self.class.custom_keys.fetch(name)
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/ptimelog/script.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Ptimelog
|
|
|
10
10
|
|
|
11
11
|
def inferer(name)
|
|
12
12
|
return NullPathname.new if name.to_s.empty?
|
|
13
|
-
raise if
|
|
13
|
+
raise if %r{[\\/]}.match?(name) # prevent relavtive paths, stupidly, FIXME: really check FS
|
|
14
14
|
|
|
15
15
|
@config_dir.join('inferers').join(name).expand_path
|
|
16
16
|
end
|
data/lib/ptimelog/timelog.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'singleton'
|
|
3
4
|
require 'pathname'
|
|
4
5
|
|
|
5
6
|
module Ptimelog
|
|
@@ -13,7 +14,19 @@ module Ptimelog
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def timelog_txt
|
|
16
|
-
Pathname.new(Configuration.instance[:
|
|
17
|
+
Pathname.new(Configuration.instance[:timelog_txt]).expand_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def previous_entry
|
|
21
|
+
lines = timelog_txt.readlines.last(10)
|
|
22
|
+
|
|
23
|
+
last_line = lines.map(&:chomp).delete_if do |line|
|
|
24
|
+
line.empty? || line.end_with?('**')
|
|
25
|
+
end.last
|
|
26
|
+
|
|
27
|
+
last_entry = instance.tokenize(last_line)
|
|
28
|
+
|
|
29
|
+
Entry.from_timelog(last_entry)
|
|
17
30
|
end
|
|
18
31
|
end
|
|
19
32
|
|
|
@@ -25,10 +38,23 @@ module Ptimelog
|
|
|
25
38
|
self.class.timelog_txt
|
|
26
39
|
end
|
|
27
40
|
|
|
41
|
+
def previous_entry
|
|
42
|
+
self.class.previous_entry
|
|
43
|
+
end
|
|
44
|
+
|
|
28
45
|
def read
|
|
29
46
|
timelog_txt.read
|
|
30
47
|
end
|
|
31
48
|
|
|
49
|
+
# TODO: spec and activate this code
|
|
50
|
+
# def add(_entry)
|
|
51
|
+
# @new_lines = []
|
|
52
|
+
# add_empty_line if @timelog.previous_entry.date == yesterday
|
|
53
|
+
# add_entry(*parse_task(task))
|
|
54
|
+
#
|
|
55
|
+
# save_file
|
|
56
|
+
# end
|
|
57
|
+
|
|
32
58
|
def parse(data)
|
|
33
59
|
data.split("\n")
|
|
34
60
|
.map { |line| tokenize(line) }
|
|
@@ -46,5 +72,44 @@ module Ptimelog
|
|
|
46
72
|
regexp = /^#{re_date} #{re_time}: #{re_tick}?#{re_desc}#{re_tags}?$/
|
|
47
73
|
line.match(regexp)
|
|
48
74
|
end
|
|
75
|
+
|
|
76
|
+
# TODO: spec and maybe activate this code. this might be covered by the "add"-command already.
|
|
77
|
+
# def parse_task(line)
|
|
78
|
+
# matches = line.match('(?<time>\d{1,2}:\d{2} )?(?<offset>[+-]\d+ )?(?<task>.*)')
|
|
79
|
+
# formatted_time = if matches[:time]
|
|
80
|
+
# Time.parse(matches[:time])
|
|
81
|
+
# else
|
|
82
|
+
# Time.now
|
|
83
|
+
# end
|
|
84
|
+
# .localtime
|
|
85
|
+
# .then { |time| time + (matches[:offset].to_i * 60) }
|
|
86
|
+
# .strftime('%F %R')
|
|
87
|
+
#
|
|
88
|
+
# [formatted_time, matches[:task]]
|
|
89
|
+
# end
|
|
90
|
+
|
|
91
|
+
# TODO: spec and activate this code.
|
|
92
|
+
# def add_entry(date_time, task)
|
|
93
|
+
# @new_lines << "#{date_time}: #{task}"
|
|
94
|
+
# end
|
|
95
|
+
|
|
96
|
+
# TODO: spec and activate this code.
|
|
97
|
+
# def add_empty_line
|
|
98
|
+
# @new_lines << ''
|
|
99
|
+
# end
|
|
100
|
+
|
|
101
|
+
# TODO: spec and activate this code.
|
|
102
|
+
# def save_file
|
|
103
|
+
# @timelog.timelog_txt.open('a') do |log|
|
|
104
|
+
# @new_lines.each do |line|
|
|
105
|
+
# log << "#{line}\n"
|
|
106
|
+
# end
|
|
107
|
+
# end
|
|
108
|
+
# end
|
|
109
|
+
|
|
110
|
+
# TODO: spec and activate this code.
|
|
111
|
+
# def yesterday
|
|
112
|
+
# NamedDate.new.named_date('yesterday')
|
|
113
|
+
# end
|
|
49
114
|
end
|
|
50
115
|
end
|
data/lib/ptimelog/version.rb
CHANGED
|
@@ -15,6 +15,14 @@
|
|
|
15
15
|
# any remaining questions, please open an issue or even better a pull-request
|
|
16
16
|
# with an improvement. Keep in mind that this is also covered by rspec so I
|
|
17
17
|
# expect (pun intended) 100% test-coverage for any additional code.
|
|
18
|
+
#
|
|
19
|
+
# Without much preparation came the addition of cmdparse to this gem. While
|
|
20
|
+
# adding it, it became apparent that a duplication between the gemspec and the
|
|
21
|
+
# main executable may exist. In order to avoid this dreadfully duplicated
|
|
22
|
+
# description, a constant for this has been added to this module. So, the
|
|
23
|
+
# correct subtitle of this documentation-block would be "and a Banner not named
|
|
24
|
+
# Bruce."
|
|
18
25
|
module Ptimelog
|
|
19
|
-
VERSION = '0.
|
|
26
|
+
VERSION = '0.12.0'
|
|
27
|
+
BANNER = 'Move time-entries from gTimelog or Obsidian to PuzzleTime'
|
|
20
28
|
end
|
data/lib/ptimelog.rb
CHANGED
|
@@ -6,20 +6,31 @@ $LOAD_PATH.unshift File.dirname(__FILE__)
|
|
|
6
6
|
module Ptimelog
|
|
7
7
|
autoload :App, 'ptimelog/app'
|
|
8
8
|
autoload :Configuration, 'ptimelog/configuration'
|
|
9
|
-
autoload :
|
|
9
|
+
autoload :DataSource, 'ptimelog/data_source'
|
|
10
10
|
autoload :DeprecationWarning, 'ptimelog/deprecation_warning'
|
|
11
11
|
autoload :Entry, 'ptimelog/entry'
|
|
12
|
+
autoload :Error, 'ptimelog/error'
|
|
13
|
+
autoload :Joiner, 'ptimelog/joiner'
|
|
12
14
|
autoload :NamedDate, 'ptimelog/named_date'
|
|
13
15
|
autoload :NullPathname, 'ptimelog/null_pathname'
|
|
14
16
|
autoload :Script, 'ptimelog/script'
|
|
15
|
-
autoload :Timelog, 'ptimelog/timelog'
|
|
16
17
|
autoload :VERSION, 'ptimelog/version'
|
|
17
18
|
|
|
19
|
+
# datasources
|
|
20
|
+
|
|
21
|
+
# obsidian
|
|
22
|
+
autoload :Operon, 'ptimelog/operon'
|
|
23
|
+
|
|
24
|
+
# timelog.txt
|
|
25
|
+
autoload :Day, 'ptimelog/day'
|
|
26
|
+
autoload :Timelog, 'ptimelog/timelog'
|
|
27
|
+
|
|
18
28
|
# Collection of commands available at the CLI
|
|
19
29
|
module Command
|
|
20
30
|
autoload :Add, 'ptimelog/command/add'
|
|
21
|
-
autoload :
|
|
31
|
+
autoload :Confirm, 'ptimelog/command/confirm'
|
|
22
32
|
autoload :Edit, 'ptimelog/command/edit'
|
|
33
|
+
autoload :Info, 'ptimelog/command/info'
|
|
23
34
|
autoload :Show, 'ptimelog/command/show'
|
|
24
35
|
autoload :Upload, 'ptimelog/command/upload'
|
|
25
36
|
autoload :Version, 'ptimelog/command/version'
|
data/opencode.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://opencode.ai/config.json",
|
|
3
|
+
"agent": {
|
|
4
|
+
"researcher": {
|
|
5
|
+
"permission": {
|
|
6
|
+
"edit": "deny",
|
|
7
|
+
"skill": "deny",
|
|
8
|
+
"question": "allow",
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"assistant": {
|
|
12
|
+
"permission": {
|
|
13
|
+
"bash": {
|
|
14
|
+
"*": "ask",
|
|
15
|
+
"rspec*": "allow",
|
|
16
|
+
"rubocop*": "allow",
|
|
17
|
+
"rake*": "allow",
|
|
18
|
+
"git status*": "allow",
|
|
19
|
+
"git diff*": "allow",
|
|
20
|
+
"git log*": "allow",
|
|
21
|
+
"git show*": "allow",
|
|
22
|
+
"git add --intent-to-add*": "allow",
|
|
23
|
+
"git add*": "ask",
|
|
24
|
+
"git commit*": "ask",
|
|
25
|
+
"git push*": "deny",
|
|
26
|
+
"ls*": "allow",
|
|
27
|
+
"tail *": "allow",
|
|
28
|
+
"head *": "allow",
|
|
29
|
+
"grep*": "allow",
|
|
30
|
+
"cat*": "allow",
|
|
31
|
+
"pwd": "allow"
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|