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.
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,6 @@
1
+ ir "./element"
2
+ ir "./mps"
3
+ ir "./note"
4
+ ir "./task"
5
+ ir "./reminder"
6
+ ir "./log"
@@ -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
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MPS
4
+ module Elements
5
+ class MPS
6
+ SIGNATURE_STAMP = "mps"
7
+ SIGNATURE_REGEX = /\Amps\z/
8
+ include Element
9
+
10
+ def self.parse_args(raw)
11
+ { tags: Element.split_args(raw)[:tags] }
12
+ end
13
+ end
14
+ end
15
+ end