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.
@@ -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 Date.today.prev_day.to_s
14
+ when 'yesterday' then yesterday
15
15
  when 'today' then Date.today.to_s
16
- when 'last', '' then timelog.to_h.keys.compact.sort[-2] || Date.today.prev_day.to_s
17
- when /\d{4}(-\d{2}){2}/ then date
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
@@ -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 name =~ %r{[\\/]} # prevent relavtive paths, stupidly, FIXME: really check FS
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
@@ -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[:timelog]).expand_path
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
@@ -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.9.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 :Day, 'ptimelog/day'
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 :Base, 'ptimelog/command/base'
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
+