mps 0.5.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/.github/workflows/main.yml +29 -0
- data/.gitignore +13 -0
- data/CLAUDE.md +102 -0
- data/GETTING_STARTED.md +447 -0
- data/Gemfile +6 -0
- data/IMPROVEMENTS.md +90 -0
- data/README.md +183 -0
- data/Rakefile +18 -0
- data/bin/console +38 -0
- data/bin/setup +8 -0
- data/exe/mps +5 -0
- data/lib/cli/mps.rb +442 -0
- data/lib/mps/config.rb +64 -0
- data/lib/mps/constants.rb +68 -0
- data/lib/mps/elements/element.rb +49 -0
- data/lib/mps/elements/elements.rb +6 -0
- data/lib/mps/elements/log.rb +32 -0
- data/lib/mps/elements/mps.rb +15 -0
- data/lib/mps/elements/note.rb +15 -0
- data/lib/mps/elements/reminder.rb +16 -0
- data/lib/mps/elements/task.rb +19 -0
- data/lib/mps/engines/engines.rb +1 -0
- data/lib/mps/engines/mps.rb +108 -0
- data/lib/mps/interpolators/interpolators.rb +1 -0
- data/lib/mps/interpolators/time.rb +11 -0
- data/lib/mps/mps.rb +32 -0
- data/lib/mps/store.rb +75 -0
- data/lib/mps/version.rb +5 -0
- data/lib/mps.rb +21 -0
- data/mps.gemspec +49 -0
- data/rust_rollout_spec.md +935 -0
- metadata +258 -0
data/lib/cli/mps.rb
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'csv'
|
|
7
|
+
require 'cli/ui'
|
|
8
|
+
|
|
9
|
+
module MPS
|
|
10
|
+
module CLI
|
|
11
|
+
class MPS < Thor
|
|
12
|
+
include Thor::Actions
|
|
13
|
+
class_option :verbose, type: :boolean, default: false
|
|
14
|
+
class_option :config_path, type: :string, default: ::MPS::Constants::MPS_CONFIG_FILE,
|
|
15
|
+
desc: "mps config file path"
|
|
16
|
+
class_option :force, type: :boolean, default: false
|
|
17
|
+
default_task :open
|
|
18
|
+
|
|
19
|
+
VALID_TYPES = %w[task note log reminder].freeze
|
|
20
|
+
|
|
21
|
+
def self.exit_on_failure?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# ── version ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
desc "version", "Print version"
|
|
28
|
+
def version
|
|
29
|
+
say "mps (v#{::MPS::VERSION})"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# ── open ───────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
desc "open [DATESIGN]", "Open .mps file in editor (default: today)"
|
|
35
|
+
def open(datesign = "today")
|
|
36
|
+
init
|
|
37
|
+
begin
|
|
38
|
+
date = ::MPS.get_date(datesign)
|
|
39
|
+
store = ::MPS::Store.new(@config.storage_dir)
|
|
40
|
+
files = store.find_files(date)
|
|
41
|
+
file_path = if files.size > 1
|
|
42
|
+
::CLI::UI::Prompt.ask("#{files.size} files found:") do |h|
|
|
43
|
+
files.each { |f| h.option(File.basename(f)) { |_| f } }
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
store.find_or_create_path(date)
|
|
47
|
+
end
|
|
48
|
+
@config.logger.info("Open MPS in text editor\n")
|
|
49
|
+
written = ::MPS.open_editor(file_path)
|
|
50
|
+
@config.logger.info("Done written Size: #{written} bytes\n")
|
|
51
|
+
say_status :written, "#{written} bytes", :green
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
raise Thor::Error, e
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ── list ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
desc "list [DATESIGN]", "List elements for a date (default: today)"
|
|
60
|
+
method_option :type, type: :string, aliases: "-t",
|
|
61
|
+
desc: "Filter by type: task, note, log, reminder"
|
|
62
|
+
method_option :tag, type: :string, aliases: "-g", desc: "Filter by tag"
|
|
63
|
+
method_option :status, type: :string, aliases: "-s",
|
|
64
|
+
desc: "Filter tasks by status: open, done"
|
|
65
|
+
method_option :since, type: :string, aliases: "-S",
|
|
66
|
+
desc: "Show elements from SINCE up to DATESIGN"
|
|
67
|
+
def list(datesign = "today")
|
|
68
|
+
init
|
|
69
|
+
begin
|
|
70
|
+
store = ::MPS::Store.new(@config.storage_dir)
|
|
71
|
+
date = ::MPS.get_date(datesign)
|
|
72
|
+
dates = options[:since] ? date_range(options[:since], date) : [date.to_date]
|
|
73
|
+
|
|
74
|
+
shown = 0
|
|
75
|
+
dates.each do |d|
|
|
76
|
+
all = store.parse_date(d)
|
|
77
|
+
next if all.empty?
|
|
78
|
+
say set_color("── #{d.strftime('%Y-%m-%d')} ─────────────", :white) if dates.size > 1
|
|
79
|
+
shown += print_tree(all, options)
|
|
80
|
+
end
|
|
81
|
+
say set_color("(no elements found)", :yellow) if shown.zero?
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
raise Thor::Error, e
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# ── append ─────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
desc "append TYPE BODY", "Append an element to today's file without opening Vim"
|
|
90
|
+
method_option :tags, type: :string, desc: "Comma-separated tags (e.g. work,release)"
|
|
91
|
+
method_option :status, type: :string, desc: "Task status: open (default) or done"
|
|
92
|
+
method_option :at, type: :string, desc: "Time for reminders (e.g. '3pm')"
|
|
93
|
+
method_option :start_time, type: :string, desc: "Start time for logs (HH:MM)"
|
|
94
|
+
method_option :end_time, type: :string, desc: "End time for logs (HH:MM)"
|
|
95
|
+
def append(type, *body_parts)
|
|
96
|
+
init
|
|
97
|
+
begin
|
|
98
|
+
type = type.downcase
|
|
99
|
+
unless VALID_TYPES.include?(type)
|
|
100
|
+
raise Thor::Error, "Unknown type '#{type}'. Valid: #{VALID_TYPES.join(', ')}"
|
|
101
|
+
end
|
|
102
|
+
body = body_parts.join(" ")
|
|
103
|
+
tags = options[:tags]&.split(",")&.map(&:strip) || []
|
|
104
|
+
attrs = {}
|
|
105
|
+
attrs[:status] = options[:status] if options[:status]
|
|
106
|
+
attrs[:at] = options[:at] if options[:at]
|
|
107
|
+
attrs[:start] = options[:start_time] if options[:start_time]
|
|
108
|
+
attrs[:end] = options[:end_time] if options[:end_time]
|
|
109
|
+
|
|
110
|
+
store = ::MPS::Store.new(@config.storage_dir)
|
|
111
|
+
path = store.append(type: type, body: body, tags: tags, attrs: attrs)
|
|
112
|
+
say_status :appended, "#{type_badge(type)} #{body}", :green
|
|
113
|
+
@config.logger.info("Appended #{type} to #{File.basename(path)}\n")
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
raise Thor::Error, e
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ── search ─────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
desc "search QUERY", "Full-text search across all .mps files"
|
|
122
|
+
method_option :type, type: :string, aliases: "-t", desc: "Filter by type"
|
|
123
|
+
method_option :tag, type: :string, aliases: "-g", desc: "Filter by tag"
|
|
124
|
+
method_option :since, type: :string, aliases: "-S", desc: "Search from this date onward"
|
|
125
|
+
def search(query)
|
|
126
|
+
init
|
|
127
|
+
begin
|
|
128
|
+
store = ::MPS::Store.new(@config.storage_dir)
|
|
129
|
+
since_date = options[:since] ? ::MPS.get_date(options[:since]).to_date : nil
|
|
130
|
+
results = store.search(
|
|
131
|
+
query,
|
|
132
|
+
type_filter: options[:type]&.downcase,
|
|
133
|
+
tag_filter: options[:tag],
|
|
134
|
+
since_date: since_date
|
|
135
|
+
)
|
|
136
|
+
if results.empty?
|
|
137
|
+
say set_color("No results for '#{query}'", :yellow)
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
results.each do |r|
|
|
141
|
+
el = r[:element]
|
|
142
|
+
tags_str = el.tags.empty? ? "" : " #{set_color("[#{el.tags.join(', ')}]", :white)}"
|
|
143
|
+
body_line = el.body_str.strip.lines.first&.strip
|
|
144
|
+
say "#{set_color(r[:date_str], :white)} #{type_badge(el.class::SIGNATURE_STAMP)} " \
|
|
145
|
+
"#{element_extra(el)}#{body_line}#{tags_str}"
|
|
146
|
+
end
|
|
147
|
+
say set_color("(#{results.size} result#{results.size == 1 ? '' : 's'})", :white)
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
raise Thor::Error, e
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ── stats ──────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
desc "stats [DATESIGN]", "Show element counts and log durations"
|
|
156
|
+
method_option :since, type: :string, aliases: "-S",
|
|
157
|
+
desc: "Stats from SINCE up to DATESIGN"
|
|
158
|
+
def stats(datesign = "today")
|
|
159
|
+
init
|
|
160
|
+
begin
|
|
161
|
+
store = ::MPS::Store.new(@config.storage_dir)
|
|
162
|
+
date = ::MPS.get_date(datesign)
|
|
163
|
+
dates = options[:since] ? date_range(options[:since], date) : [date.to_date]
|
|
164
|
+
|
|
165
|
+
total = Hash.new(0)
|
|
166
|
+
total_log_mins = 0
|
|
167
|
+
any = false
|
|
168
|
+
|
|
169
|
+
dates.each do |d|
|
|
170
|
+
elements = store.parse_date(d).values
|
|
171
|
+
.reject { |e| e.is_a?(::MPS::Elements::MPS) }
|
|
172
|
+
next if elements.empty?
|
|
173
|
+
any = true
|
|
174
|
+
counts = elements.group_by { |e| e.class::SIGNATURE_STAMP }.transform_values(&:size)
|
|
175
|
+
log_mins = elements.select { |e| e.is_a?(::MPS::Elements::Log) }
|
|
176
|
+
.sum { |e| e.duration_minutes || 0 }
|
|
177
|
+
tasks = elements.select { |e| e.is_a?(::MPS::Elements::Task) }
|
|
178
|
+
|
|
179
|
+
parts = []
|
|
180
|
+
if (n = counts["task"])
|
|
181
|
+
open_n = tasks.count(&:open?)
|
|
182
|
+
done_n = tasks.count(&:done?)
|
|
183
|
+
parts << "#{n} task#{n != 1 ? 's' : ''} " \
|
|
184
|
+
"(#{set_color("#{open_n} open", :yellow)}, " \
|
|
185
|
+
"#{set_color("#{done_n} done", :green)})"
|
|
186
|
+
end
|
|
187
|
+
parts << "#{counts['note']} note#{counts['note'] != 1 ? 's' : ''}" if counts["note"]
|
|
188
|
+
parts << "#{counts['reminder']} reminder#{counts['reminder'] != 1 ? 's':''}" if counts["reminder"]
|
|
189
|
+
if (n = counts["log"])
|
|
190
|
+
h, m = log_mins.divmod(60)
|
|
191
|
+
dur = log_mins > 0 ? " (#{h}h#{m > 0 ? "#{m}m" : ""})" : ""
|
|
192
|
+
parts << "#{n} log#{n != 1 ? 's' : ''}#{dur}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
say "#{set_color(d.strftime('%Y-%m-%d'), :white)} — #{parts.join(', ')}"
|
|
196
|
+
counts.each { |k, v| total[k] += v }
|
|
197
|
+
total_log_mins += log_mins
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
say set_color("(no data found)", :yellow) unless any
|
|
201
|
+
|
|
202
|
+
if dates.size > 1 && any
|
|
203
|
+
say set_color("─" * 44, :white)
|
|
204
|
+
tparts = []
|
|
205
|
+
tparts << "#{total['task']} tasks" if total["task"] > 0
|
|
206
|
+
tparts << "#{total['note']} notes" if total["note"] > 0
|
|
207
|
+
tparts << "#{total['reminder']} reminders" if total["reminder"] > 0
|
|
208
|
+
if total["log"] > 0
|
|
209
|
+
h, m = total_log_mins.divmod(60)
|
|
210
|
+
dur = total_log_mins > 0 ? " (#{h}h#{m > 0 ? "#{m}m" : ""} total)" : ""
|
|
211
|
+
tparts << "#{total['log']} logs#{dur}"
|
|
212
|
+
end
|
|
213
|
+
say "Total: #{tparts.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
raise Thor::Error, e
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ── export ─────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
desc "export [DATESIGN]", "Export elements to JSON or CSV (writes to stdout)"
|
|
223
|
+
method_option :format, type: :string, default: "json", aliases: "-f",
|
|
224
|
+
desc: "Output format: json, csv"
|
|
225
|
+
method_option :type, type: :string, aliases: "-t", desc: "Filter by type"
|
|
226
|
+
method_option :since, type: :string, aliases: "-S",
|
|
227
|
+
desc: "Export from SINCE up to DATESIGN"
|
|
228
|
+
def export(datesign = "today")
|
|
229
|
+
init
|
|
230
|
+
begin
|
|
231
|
+
store = ::MPS::Store.new(@config.storage_dir)
|
|
232
|
+
date = ::MPS.get_date(datesign)
|
|
233
|
+
dates = options[:since] ? date_range(options[:since], date) : [date.to_date]
|
|
234
|
+
fmt = options[:format].downcase
|
|
235
|
+
|
|
236
|
+
unless %w[json csv].include?(fmt)
|
|
237
|
+
raise Thor::Error, "Unknown format '#{fmt}'. Use: json, csv"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
records = []
|
|
241
|
+
dates.each do |d|
|
|
242
|
+
store.parse_date(d).each do |ref, el|
|
|
243
|
+
next if el.is_a?(::MPS::Elements::MPS)
|
|
244
|
+
next if options[:type] && el.class::SIGNATURE_STAMP != options[:type].downcase
|
|
245
|
+
extra = el.parsed_args.reject { |k, _| k == :tags }
|
|
246
|
+
records << {
|
|
247
|
+
date: d.strftime("%Y-%m-%d"),
|
|
248
|
+
ref: ref,
|
|
249
|
+
type: el.class::SIGNATURE_STAMP,
|
|
250
|
+
tags: el.tags.join(","),
|
|
251
|
+
body: el.body_str.strip
|
|
252
|
+
}.merge(extra)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
if fmt == "json"
|
|
257
|
+
say JSON.pretty_generate(records)
|
|
258
|
+
else
|
|
259
|
+
keys = %i[date ref type tags body] +
|
|
260
|
+
(records.flat_map(&:keys) - %i[date ref type tags body]).uniq
|
|
261
|
+
say CSV.generate { |csv|
|
|
262
|
+
csv << keys.map(&:to_s)
|
|
263
|
+
records.each { |r| csv << keys.map { |k| r[k] } }
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
raise Thor::Error, e
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# ── git / autogit / cmd ────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
desc "git GITCOMMAND", "Run git commands inside storage_dir"
|
|
274
|
+
def git(*commands)
|
|
275
|
+
init
|
|
276
|
+
begin
|
|
277
|
+
git_command = if commands.first == "auto"
|
|
278
|
+
"git add . && git commit -m \"$(date)\" && " \
|
|
279
|
+
"git pull #{@config.git_remote} #{@config.git_branch} && " \
|
|
280
|
+
"git push #{@config.git_remote} #{@config.git_branch}"
|
|
281
|
+
elsif commands.first == "autocommit"
|
|
282
|
+
"git add . && git commit -m \"$(date)\""
|
|
283
|
+
elsif commands.size > 0
|
|
284
|
+
cmds = commands.map { |c| c.include?(" ") ? "\"#{c}\"" : c }
|
|
285
|
+
"git #{cmds.join(' ')}"
|
|
286
|
+
else
|
|
287
|
+
"git status"
|
|
288
|
+
end
|
|
289
|
+
inside(@config.storage_dir) { run git_command }
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
raise Thor::Error, e
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
desc "autogit", "Auto stage, commit, pull and push"
|
|
296
|
+
def autogit
|
|
297
|
+
init
|
|
298
|
+
begin
|
|
299
|
+
cmd = "git add . && git commit -m \"$(date)\" && " \
|
|
300
|
+
"git pull #{@config.git_remote} #{@config.git_branch} && " \
|
|
301
|
+
"git push #{@config.git_remote} #{@config.git_branch}"
|
|
302
|
+
inside(@config.storage_dir) { run cmd }
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
raise Thor::Error, e
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
desc "cmd COMMAND", "Run shell commands inside storage_dir"
|
|
309
|
+
def cmd(*commands)
|
|
310
|
+
init
|
|
311
|
+
begin
|
|
312
|
+
cmds = commands.map { |c| c.include?(" ") ? "\"#{c}\"" : c }
|
|
313
|
+
inside(@config.storage_dir) { run cmds.join(" ") }
|
|
314
|
+
rescue StandardError => e
|
|
315
|
+
raise Thor::Error, e
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
# ── config helpers ─────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
def init
|
|
324
|
+
@config = load_config(options[:config_path], force: options[:force])
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
say_status "error", "failed to initialize"
|
|
327
|
+
raise Thor::Error, e
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def load_config(config_path, force: false)
|
|
331
|
+
::MPS::Config.init(config_path) if !File.exist?(config_path) || force
|
|
332
|
+
::MPS::Config.new(**load_tangible_config_hash(config_path))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def load_tangible_config_hash(config_path)
|
|
336
|
+
conf_hash = ::MPS::Config.load_conf_hash(config_path)
|
|
337
|
+
empty_directory conf_hash[:storage_dir] unless Dir.exist?(conf_hash[:storage_dir])
|
|
338
|
+
empty_directory conf_hash[:mps_dir] unless Dir.exist?(conf_hash[:mps_dir])
|
|
339
|
+
create_file conf_hash[:log_file] unless File.exist?(conf_hash[:log_file])
|
|
340
|
+
conf_hash
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# ── display helpers ────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
TYPE_COLORS = {
|
|
346
|
+
"task" => :green,
|
|
347
|
+
"note" => :cyan,
|
|
348
|
+
"reminder" => :magenta,
|
|
349
|
+
"log" => :yellow
|
|
350
|
+
}.freeze
|
|
351
|
+
|
|
352
|
+
def type_badge(type_name)
|
|
353
|
+
color = TYPE_COLORS.fetch(type_name, :white)
|
|
354
|
+
set_color("[#{type_name}]", color)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def element_extra(el)
|
|
358
|
+
case el
|
|
359
|
+
when ::MPS::Elements::Task
|
|
360
|
+
status = el.parsed_args[:status] || "open"
|
|
361
|
+
color = status == "done" ? :green : :yellow
|
|
362
|
+
"(#{set_color(status, color)}) "
|
|
363
|
+
when ::MPS::Elements::Log
|
|
364
|
+
dur = el.duration_str
|
|
365
|
+
dur ? "(#{set_color(dur, :yellow)}) " : ""
|
|
366
|
+
when ::MPS::Elements::Reminder
|
|
367
|
+
at = el.parsed_args[:at]
|
|
368
|
+
at ? "(#{set_color(at, :magenta)}) " : ""
|
|
369
|
+
else
|
|
370
|
+
""
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def print_element(el, depth: 0)
|
|
375
|
+
indent = " " * (depth + 1)
|
|
376
|
+
type_name = el.class::SIGNATURE_STAMP
|
|
377
|
+
tags_str = el.tags.empty? ? "" : " #{set_color("[#{el.tags.join(', ')}]", :white)}"
|
|
378
|
+
body_line = el.body_str.strip.lines.first&.strip
|
|
379
|
+
say "#{indent}#{type_badge(type_name)} #{element_extra(el)}#{body_line}#{tags_str}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Renders elements_hash as an indented tree ordered by ref path.
|
|
383
|
+
# @mps containers show as group headers; the synthetic root wrapper is skipped.
|
|
384
|
+
# Returns the count of non-MPS elements actually printed.
|
|
385
|
+
def print_tree(elements_hash, opts)
|
|
386
|
+
sorted = elements_hash.sort_by { |k, _| k.split(".").map(&:to_i) }
|
|
387
|
+
return 0 if sorted.empty?
|
|
388
|
+
|
|
389
|
+
root_segs = sorted.first.first.split(".").size # always 1 (just the epoch)
|
|
390
|
+
shown = 0
|
|
391
|
+
|
|
392
|
+
sorted.each do |ref_key, el|
|
|
393
|
+
depth = ref_key.split(".").size - root_segs - 1
|
|
394
|
+
next if depth < 0 # root synthetic @mps wrapper
|
|
395
|
+
|
|
396
|
+
if el.is_a?(::MPS::Elements::MPS)
|
|
397
|
+
# Only show @mps group header when it has at least one visible child.
|
|
398
|
+
prefix = "#{ref_key}."
|
|
399
|
+
any_visible = elements_hash.any? do |k, v|
|
|
400
|
+
k.start_with?(prefix) && !v.is_a?(::MPS::Elements::MPS) && visible?(v, opts)
|
|
401
|
+
end
|
|
402
|
+
next unless any_visible
|
|
403
|
+
indent = " " * (depth + 1)
|
|
404
|
+
say "#{indent}#{set_color("[@mps]", :white)}"
|
|
405
|
+
else
|
|
406
|
+
next unless visible?(el, opts)
|
|
407
|
+
print_element(el, depth: depth)
|
|
408
|
+
shown += 1
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
shown
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def visible?(el, opts)
|
|
415
|
+
type_f = opts[:type]&.downcase
|
|
416
|
+
tag_f = opts[:tag]
|
|
417
|
+
status_f = opts[:status]
|
|
418
|
+
return false if type_f && el.class::SIGNATURE_STAMP != type_f
|
|
419
|
+
return false if tag_f && !el.tags.include?(tag_f)
|
|
420
|
+
# --status only matches elements that carry a status field (i.e. tasks)
|
|
421
|
+
if status_f
|
|
422
|
+
s = el.respond_to?(:parsed_args) ? el.parsed_args[:status] : nil
|
|
423
|
+
return false if s.nil? || s != status_f
|
|
424
|
+
end
|
|
425
|
+
true
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# ── filtering (used by search/stats helpers) ────────────────────────────
|
|
429
|
+
|
|
430
|
+
def filtered_elements(elements_hash, opts)
|
|
431
|
+
elements_hash.values
|
|
432
|
+
.reject { |e| e.is_a?(::MPS::Elements::MPS) }
|
|
433
|
+
.select { |e| visible?(e, opts) }
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def date_range(since_str, to_date)
|
|
437
|
+
since_date = ::MPS.get_date(since_str).to_date
|
|
438
|
+
(since_date..to_date.to_date).to_a
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
data/lib/mps/config.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MPS
|
|
4
|
+
# Configuration class
|
|
5
|
+
#
|
|
6
|
+
# To use an instance of the class to contain
|
|
7
|
+
# necessary configuration data and tasks
|
|
8
|
+
class Config
|
|
9
|
+
class LoadError < StandardError;end;
|
|
10
|
+
class ConfigFileAlreadyExist < StandardError;end;
|
|
11
|
+
class ConfigFileNotFound < StandardError;end;
|
|
12
|
+
class MPSDirectoryNotFound < StandardError;end;
|
|
13
|
+
class MPSStorageDirectoryNotFound < StandardError;end;
|
|
14
|
+
|
|
15
|
+
attr_reader :storage_dir
|
|
16
|
+
attr_reader :logger
|
|
17
|
+
attr_reader :log_file
|
|
18
|
+
attr_reader :git_remote
|
|
19
|
+
attr_reader :git_branch
|
|
20
|
+
def initialize(**conf_hash)
|
|
21
|
+
@mps_dir = conf_hash[:mps_dir]
|
|
22
|
+
@storage_dir = conf_hash[:storage_dir]
|
|
23
|
+
@log_file = conf_hash[:log_file]
|
|
24
|
+
@git_remote = conf_hash.fetch(:git_remote, "origin")
|
|
25
|
+
@git_branch = conf_hash.fetch(:git_branch, "master")
|
|
26
|
+
@logger = Logger.new(File.open(@log_file, "a+"))
|
|
27
|
+
@logger.formatter = proc do |sev, time, pn, msg|
|
|
28
|
+
time = time.strftime("[%Y-%m-%d %H:%M:%S]")
|
|
29
|
+
sev = {"INFO"=> "I", "WARN"=>"W", "ERROR"=>"E", "FATAL"=> "F", "DEBUG"=>"D"}[sev]
|
|
30
|
+
"#{time} #{sev}: #{msg}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Loads a yaml configuration file
|
|
35
|
+
#
|
|
36
|
+
# @param conf_fp [String] file path to the yaml config file
|
|
37
|
+
# @note This is method checks for the loaded yaml data, if it doesn't contain necessary data conforming to the contextual configuration data, should raise appropriate StandardError.
|
|
38
|
+
# @return [Hash] should return a Config hash
|
|
39
|
+
|
|
40
|
+
def self.load_conf_hash(conf_fp)
|
|
41
|
+
if File.exist?(conf_fp)
|
|
42
|
+
conf_hash = YAML.load_file(conf_fp)
|
|
43
|
+
raise LoadError.new("yaml not a hash") if conf_hash.class!=Hash
|
|
44
|
+
raise LoadError.new("storage_dir key requires") if not conf_hash[:storage_dir]
|
|
45
|
+
raise LoadError.new("log_file key requires") if not conf_hash[:log_file]
|
|
46
|
+
raise LoadError.new("mps_dir key requires") if not conf_hash[:mps_dir]
|
|
47
|
+
return conf_hash
|
|
48
|
+
end
|
|
49
|
+
raise ConfigFileNotFound.new("configs file not found")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# initialize a yaml config file for mps
|
|
53
|
+
# if the file already exists skips
|
|
54
|
+
#
|
|
55
|
+
# @param conf_fp [String] file path to the yaml config file
|
|
56
|
+
# @param force [Boolean] forces even file exists
|
|
57
|
+
# @return [Conf] Conf object
|
|
58
|
+
def self.init(conf_fp)
|
|
59
|
+
File.open(conf_fp, "w+") do |f|
|
|
60
|
+
YAML.dump(Constants::DEFAULT_CONF_HASH, f)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module MPS
|
|
3
|
+
module Constants
|
|
4
|
+
# mps file extention
|
|
5
|
+
MPS_EXT = "mps"
|
|
6
|
+
|
|
7
|
+
# user home directory
|
|
8
|
+
HOME_DIR = Dir.home
|
|
9
|
+
|
|
10
|
+
# default mps directory where all mps related files will be stored,
|
|
11
|
+
# including mps storage directory, config, log files etc.
|
|
12
|
+
MPS_DIR = File.join(HOME_DIR, ".mps")
|
|
13
|
+
|
|
14
|
+
# mps config default file path
|
|
15
|
+
MPS_CONFIG_FILE = File.join(HOME_DIR, ".mps_config.yaml")
|
|
16
|
+
|
|
17
|
+
# default mps storage directory, usually where the mps files
|
|
18
|
+
# will be stored. but should configurable to any path through
|
|
19
|
+
# config.
|
|
20
|
+
MPS_STORAGE_DIR = File.join(MPS_DIR, "mps")
|
|
21
|
+
|
|
22
|
+
# mps file name structure
|
|
23
|
+
MPS_FILE_NAME_REGEXP = Regexp.new("^(?<date-stamp>(?<year>\\d{4})(?<month>\\d{2})(?<day>\\d{2})(?<dot-epoch>\\.(?<epoch>\\d{10,}))?)\\.#{MPS_EXT}$")
|
|
24
|
+
|
|
25
|
+
# clip the mps filename except the extention, usually datestamp
|
|
26
|
+
MPS_FILE_NAME_CLIPPER = ->(file_basename){
|
|
27
|
+
m = MPS_FILE_NAME_REGEXP.match(file_basename)
|
|
28
|
+
m ? m[1] : "0"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# clip datestamp with hash accessibility from the mps filename
|
|
32
|
+
MPS_FILE_NAME_DATE_CLIPPER = ->(file_basename){
|
|
33
|
+
MPS_FILE_NAME_REGEXP=~file_basename
|
|
34
|
+
{
|
|
35
|
+
year: $~[2],
|
|
36
|
+
month: $~[3],
|
|
37
|
+
day: $~[4]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# get new file name
|
|
42
|
+
MPS_NEW_FILE_NAME_GEN = ->(date){
|
|
43
|
+
"#{date.strftime('%Y%m%d')}.#{Time.now.to_i}.#{MPS_EXT}"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# default mps log path
|
|
47
|
+
MPS_LOG_FILE = File.join(MPS_DIR, "mps.log")
|
|
48
|
+
|
|
49
|
+
# default conf hash
|
|
50
|
+
DEFAULT_CONF_HASH = {
|
|
51
|
+
mps_dir: MPS_DIR,
|
|
52
|
+
storage_dir: MPS_STORAGE_DIR,
|
|
53
|
+
log_file: MPS_LOG_FILE,
|
|
54
|
+
git_remote: "origin",
|
|
55
|
+
git_branch: "master"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# at or @[]{} signature regexps — brackets are optional: @task{ } and @task[]{ } both valid
|
|
60
|
+
AT_REGEXP_LA = /(?=@[a-zA-Z0-9_]+(?:\[[\s\S]*?\])?\s*\{)/
|
|
61
|
+
AT_REGEXP = /@(?<element_sign>[a-zA-Z0-9_]+)(?:\[(?<args>[^\]]*)\])?\s*\{/
|
|
62
|
+
|
|
63
|
+
# end curly bracket regexp — excludes } surrounded by single-quotes
|
|
64
|
+
END_CURLY_REGEXP_LA = /(?=(?<!')\}(?!'))/
|
|
65
|
+
END_CURLY_REGEXP = /(?<!')\}(?!')/
|
|
66
|
+
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MPS
|
|
4
|
+
module Element
|
|
5
|
+
PADDING = ' '
|
|
6
|
+
|
|
7
|
+
# Parses "work, release, status: done" → { attrs: { status: "done" }, tags: ["work", "release"] }
|
|
8
|
+
# Parts containing ":" become named attrs; bare words become tags.
|
|
9
|
+
def self.split_args(raw)
|
|
10
|
+
return { attrs: {}, tags: [] } if raw.nil? || raw.strip.empty?
|
|
11
|
+
attrs = {}
|
|
12
|
+
tags = []
|
|
13
|
+
raw.split(",").each do |part|
|
|
14
|
+
part = part.strip
|
|
15
|
+
next if part.empty?
|
|
16
|
+
if part.include?(":")
|
|
17
|
+
k, v = part.split(":", 2).map(&:strip)
|
|
18
|
+
attrs[k.to_sym] = v
|
|
19
|
+
else
|
|
20
|
+
tags << part
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
{ attrs: attrs, tags: tags }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_accessor :disp_str
|
|
27
|
+
attr_reader :body_str, :raw_args, :parsed_args
|
|
28
|
+
|
|
29
|
+
def initialize(args:, refs:, body_str:)
|
|
30
|
+
@raw_args = args.to_s
|
|
31
|
+
@refs = refs
|
|
32
|
+
@body_str = body_str
|
|
33
|
+
@ref = refs.map(&:to_s).join(".")
|
|
34
|
+
@parsed_args = self.class.respond_to?(:parse_args) ? self.class.parse_args(@raw_args) : {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def tags
|
|
38
|
+
@parsed_args.fetch(:tags, [])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def display_str(padding_size = @refs.size - 1)
|
|
42
|
+
strs = @body_str.strip.lines.map(&:strip)
|
|
43
|
+
header = strs.first
|
|
44
|
+
res_strs = [(PADDING * padding_size) + header]
|
|
45
|
+
strs[1..].each { |str| res_strs << (PADDING * padding_size) + str }
|
|
46
|
+
res_strs.join("\n")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MPS
|
|
4
|
+
module Elements
|
|
5
|
+
class Log
|
|
6
|
+
SIGNATURE_STAMP = "log"
|
|
7
|
+
SIGNATURE_REGEX = /\Alog\z/
|
|
8
|
+
include Element
|
|
9
|
+
|
|
10
|
+
def self.parse_args(raw)
|
|
11
|
+
p = Element.split_args(raw)
|
|
12
|
+
{ tags: p[:tags], start: p[:attrs][:start], end: p[:attrs][:end] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def duration_minutes
|
|
16
|
+
s = parsed_args[:start]
|
|
17
|
+
e = parsed_args[:end]
|
|
18
|
+
return nil unless s && e
|
|
19
|
+
sh, sm = s.split(":").map(&:to_i)
|
|
20
|
+
eh, em = e.split(":").map(&:to_i)
|
|
21
|
+
(eh * 60 + em) - (sh * 60 + sm)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def duration_str
|
|
25
|
+
mins = duration_minutes
|
|
26
|
+
return nil unless mins && mins > 0
|
|
27
|
+
h, m = mins.divmod(60)
|
|
28
|
+
m > 0 ? "#{h}h#{m}m" : "#{h}h"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|