spltty 0.1.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/LICENSE +21 -0
- data/README.md +351 -0
- data/config.example.json +33 -0
- data/exe/spltty +11 -0
- data/lib/spltty_cli/commands/add.rb +274 -0
- data/lib/spltty_cli/commands/groups.rb +47 -0
- data/lib/spltty_cli/commands/groups_add.rb +73 -0
- data/lib/spltty_cli/commands/groups_rm.rb +73 -0
- data/lib/spltty_cli/commands/help.rb +44 -0
- data/lib/spltty_cli/commands/install.rb +407 -0
- data/lib/spltty_cli/commands/list.rb +37 -0
- data/lib/spltty_cli/commands/methods.rb +33 -0
- data/lib/spltty_cli/commands/methods_add.rb +58 -0
- data/lib/spltty_cli/commands/settle.rb +302 -0
- data/lib/spltty_cli/commands/sync.rb +41 -0
- data/lib/spltty_cli/commands/totals.rb +85 -0
- data/lib/spltty_cli/config.rb +140 -0
- data/lib/spltty_cli/discovery.rb +95 -0
- data/lib/spltty_cli/groups.rb +60 -0
- data/lib/spltty_cli/ledger.rb +68 -0
- data/lib/spltty_cli/notes.rb +67 -0
- data/lib/spltty_cli/notes_sync.rb +137 -0
- data/lib/spltty_cli/prompt.rb +75 -0
- data/lib/spltty_cli/table.rb +95 -0
- data/lib/spltty_cli/totals.rb +222 -0
- data/lib/spltty_cli/version.rb +5 -0
- data/lib/spltty_cli.rb +98 -0
- data/templates/CLAUDE.md.erb +229 -0
- data/templates/gitignore +4 -0
- data/templates/notes.md.erb +39 -0
- data/templates/skills/ingest/SKILL.md +66 -0
- data/templates/sources-INDEX.md +13 -0
- metadata +94 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
# Split-group helpers. A group is a name -> { participant => percentage } map,
|
|
5
|
+
# e.g. { "Thiago" => 70, "Camila" => 30 }. Percentages are integers; a group
|
|
6
|
+
# should sum to 100 (validation warns, never hard-fails — Totals normalizes by
|
|
7
|
+
# the sum anyway). Groups are resolved at settlement time by `spltty totals`
|
|
8
|
+
# (see Totals): a Responsible value naming a group is split by it; anything
|
|
9
|
+
# else is treated as a person.
|
|
10
|
+
module Groups
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Parse "Thiago:70,Camila:30" -> { "Thiago" => 70, "Camila" => 30 }.
|
|
14
|
+
# Raises ArgumentError on malformed input.
|
|
15
|
+
def parse_split(str)
|
|
16
|
+
raise ArgumentError, "empty split" if str.nil? || str.strip.empty?
|
|
17
|
+
|
|
18
|
+
str.split(",").each_with_object({}) do |pair, h|
|
|
19
|
+
name, pct = pair.split(":", 2).map(&:strip)
|
|
20
|
+
raise ArgumentError, "bad split segment #{pair.inspect} (expected NAME:PCT)" if name.nil? || name.empty? || pct.nil?
|
|
21
|
+
raise ArgumentError, "non-integer percentage in #{pair.inspect}" unless pct.match?(/\A\d+\z/)
|
|
22
|
+
|
|
23
|
+
h[name] = pct.to_i
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Human-readable one-line form: "Thiago 70, Camila 30".
|
|
28
|
+
def format(hash)
|
|
29
|
+
hash.map { |name, pct| "#{name} #{pct}" }.join(", ")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Round-trip form accepted by parse_split: "Thiago:70,Camila:30". Used to
|
|
33
|
+
# pre-fill prompts and examples with a value the user can edit in place.
|
|
34
|
+
def format_split(hash)
|
|
35
|
+
hash.map { |name, pct| "#{name}:#{pct}" }.join(",")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Warn (on $stderr) when percentages are not integers or don't sum to 100.
|
|
39
|
+
# Returns true when clean, false when a warning was emitted.
|
|
40
|
+
def validate(name, hash)
|
|
41
|
+
unless hash.is_a?(Hash) && !hash.empty? && hash.values.all? { |v| v.is_a?(Integer) }
|
|
42
|
+
warn "spltty: group #{name.inspect} has non-integer or empty percentages"
|
|
43
|
+
return false
|
|
44
|
+
end
|
|
45
|
+
sum = hash.values.sum
|
|
46
|
+
if sum != 100
|
|
47
|
+
warn "spltty: group #{name.inspect} percentages sum to #{sum}, not 100"
|
|
48
|
+
return false
|
|
49
|
+
end
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Order-independent comparable key for a group definition: the name plus its
|
|
54
|
+
# participant=>pct pairs sorted by participant. Used to detect the same
|
|
55
|
+
# definition across ledgers for global promotion.
|
|
56
|
+
def canonical(name, hash)
|
|
57
|
+
[name, hash.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, v] }]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module SplttyCLI
|
|
7
|
+
# Resolve a ledger name to a concrete file path / schema / title, and scaffold
|
|
8
|
+
# the entries file (title + notes pointer + table header) when it is missing.
|
|
9
|
+
module Ledger
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
HEADERS = {
|
|
13
|
+
"standard" => [
|
|
14
|
+
"| Date | Title | Value (R$) | Paid By | Payment Method | Responsible | Source |",
|
|
15
|
+
"|------------|-------|-----------:|---------|----------------|-------------|--------|",
|
|
16
|
+
],
|
|
17
|
+
"montreal" => [
|
|
18
|
+
"| Date | Title | Orig. Value | Cur. | Value (R$) | Paid By | Payment Method | Responsible | Source |",
|
|
19
|
+
"|------------|-------|------------:|------|-----------:|---------|----------------|-------------|--------|",
|
|
20
|
+
],
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def config_entry(config, name)
|
|
24
|
+
key = config.ledger_key(name)
|
|
25
|
+
key ? [key, config.ledgers[key]] : [name, nil]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def schema(entry)
|
|
29
|
+
(entry && entry["schema"]) || "standard"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def montreal?(entry)
|
|
33
|
+
schema(entry) == "montreal"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Absolute path of the entries file for `name` on `date`.
|
|
37
|
+
def target_path(config, name, entry, date)
|
|
38
|
+
if entry && entry["type"] == "monthly"
|
|
39
|
+
dir = entry["dir"] || name
|
|
40
|
+
File.join(config.accounts_dir, dir, "#{date.strftime('%Y-%m')}.md")
|
|
41
|
+
else
|
|
42
|
+
file = (entry && entry["file"]) || "#{name}.ledger.md"
|
|
43
|
+
File.join(config.accounts_dir, file)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def title(name, entry, date)
|
|
48
|
+
monthly = entry && entry["type"] == "monthly"
|
|
49
|
+
template = (entry && entry["title"]) || (monthly ? "%{name} — %{month_name} %{year}" : "%{name}")
|
|
50
|
+
format(template, name: name, month_name: date.strftime("%B"), year: date.year)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def notes_pointer(name, entry)
|
|
54
|
+
monthly = entry && entry["type"] == "monthly"
|
|
55
|
+
(entry && entry["notes"]) || (monthly ? "notes.md" : "#{name}.notes.md")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Create the entries file: title, notes-pointer blockquote, table header.
|
|
59
|
+
def scaffold(path, title_line, notes, schema)
|
|
60
|
+
header, separator = HEADERS.fetch(schema, HEADERS["standard"])
|
|
61
|
+
content = +"# #{title_line}\n\n"
|
|
62
|
+
content << "> Rules & conventions: [`#{notes}`](#{notes})\n\n"
|
|
63
|
+
content << header << "\n" << separator << "\n"
|
|
64
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
65
|
+
File.write(path, content)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
# Read / write the YAML frontmatter header of a `*.notes.md` file, and resolve
|
|
7
|
+
# a ledger's notes-file path. The header (delimited by leading `---` fences)
|
|
8
|
+
# carries ledger config under a `spltty:` sub-map; NotesSync owns that map.
|
|
9
|
+
# Any other frontmatter keys are preserved untouched on round-trip.
|
|
10
|
+
module Notes
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
FENCE = "---"
|
|
14
|
+
|
|
15
|
+
# Parse a notes file into its frontmatter + body.
|
|
16
|
+
# { frontmatter: Hash, body: String, mtime: Time }
|
|
17
|
+
# frontmatter is {} when the file has no leading `---` block (or the file
|
|
18
|
+
# is absent). body is the content after the closing fence (or the whole
|
|
19
|
+
# file when there is no frontmatter).
|
|
20
|
+
def parse(path)
|
|
21
|
+
return { frontmatter: {}, body: "", mtime: nil } unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
raw = File.read(path)
|
|
24
|
+
mtime = File.mtime(path)
|
|
25
|
+
fm, body = split(raw)
|
|
26
|
+
{ frontmatter: fm, body: body, mtime: mtime }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Split raw file content into [frontmatter Hash, body String].
|
|
30
|
+
def split(raw)
|
|
31
|
+
lines = raw.lines
|
|
32
|
+
return [{}, raw] unless lines.first&.chomp == FENCE
|
|
33
|
+
|
|
34
|
+
close = lines[1..].index { |l| l.chomp == FENCE }
|
|
35
|
+
return [{}, raw] if close.nil?
|
|
36
|
+
|
|
37
|
+
yaml = lines[1..close].join
|
|
38
|
+
body = lines[(close + 2)..]&.join || ""
|
|
39
|
+
parsed = YAML.safe_load(yaml) || {}
|
|
40
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
41
|
+
[parsed, body]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Write `frontmatter` + `body` back to `path`. When frontmatter is empty the
|
|
45
|
+
# file is written as body only (no fences).
|
|
46
|
+
def write(path, frontmatter, body)
|
|
47
|
+
content =
|
|
48
|
+
if frontmatter.nil? || frontmatter.empty?
|
|
49
|
+
body
|
|
50
|
+
else
|
|
51
|
+
# YAML.dump emits a leading "---\n"; append the closing fence.
|
|
52
|
+
"#{YAML.dump(frontmatter)}#{FENCE}\n#{body}"
|
|
53
|
+
end
|
|
54
|
+
File.write(path, content)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Absolute path of a ledger's notes file, given its config entry.
|
|
58
|
+
def path_for(accounts_dir, name, entry)
|
|
59
|
+
if entry && entry["type"] == "monthly"
|
|
60
|
+
dir = entry["dir"] || name
|
|
61
|
+
File.join(accounts_dir, dir, entry["notes"] || "notes.md")
|
|
62
|
+
else
|
|
63
|
+
File.join(accounts_dir, entry&.fetch("notes", nil) || "#{name}.notes.md")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
# Two-way sync of ledger config between each ledger's `*.notes.md` frontmatter
|
|
7
|
+
# (the `spltty:` sub-map) and the `ledgers` block of config.json.
|
|
8
|
+
#
|
|
9
|
+
# The notes file is the source of truth. A per-ledger `synced_at` timestamp in
|
|
10
|
+
# config is compared against the notes file's mtime to pick the direction:
|
|
11
|
+
# * notes edited more recently (or config never synced) -> notes -> config
|
|
12
|
+
# * config as-new-or-newer, or notes carries no managed field -> config -> notes
|
|
13
|
+
# After a config -> notes write, synced_at is stamped to the file's NEW mtime so
|
|
14
|
+
# the write does not re-trigger a notes -> config on the next run.
|
|
15
|
+
#
|
|
16
|
+
# Every managed field is optional: only keys present on the winning side are
|
|
17
|
+
# merged; an absent key is left untouched on the other side (never cleared).
|
|
18
|
+
module NotesSync
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
MANAGED = %w[title default_responsible default_currency reference groups].freeze
|
|
22
|
+
|
|
23
|
+
# Sync all ledgers in `config`. Mutates config in place (caller saves).
|
|
24
|
+
# Returns { to_config: [names], to_notes: [names], globalized: [names] }.
|
|
25
|
+
def run(config)
|
|
26
|
+
to_config = []
|
|
27
|
+
to_notes = []
|
|
28
|
+
|
|
29
|
+
config.ledgers.each do |name, entry|
|
|
30
|
+
next if entry["missing"]
|
|
31
|
+
|
|
32
|
+
path = Notes.path_for(config.accounts_dir, name, entry)
|
|
33
|
+
next unless File.exist?(path)
|
|
34
|
+
|
|
35
|
+
parsed = Notes.parse(path)
|
|
36
|
+
fm_full = parsed[:frontmatter]
|
|
37
|
+
spltty = fm_full["spltty"].is_a?(Hash) ? fm_full["spltty"] : {}
|
|
38
|
+
header_fields = MANAGED.each_with_object({}) do |k, h|
|
|
39
|
+
h[k] = spltty[k] if spltty.key?(k) && !spltty[k].nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
s = parse_time(entry["synced_at"])
|
|
43
|
+
mtime = parsed[:mtime]
|
|
44
|
+
notes_wins = !header_fields.empty? && (s.nil? || mtime > s)
|
|
45
|
+
|
|
46
|
+
if notes_wins
|
|
47
|
+
header_fields.each { |k, v| entry[k] = v }
|
|
48
|
+
entry["synced_at"] = mtime.utc.iso8601(9)
|
|
49
|
+
to_config << name
|
|
50
|
+
elsif config_to_notes(path, entry, fm_full, spltty, parsed[:body])
|
|
51
|
+
to_notes << name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{ to_config: to_config, to_notes: to_notes, globalized: globalize(config) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Cross-ledger promotion: any group definition (name + participants +
|
|
59
|
+
# percentages) that appears identically in >= 2 ledgers is recorded in the
|
|
60
|
+
# global config.groups. Additive only — per-ledger copies and headers are
|
|
61
|
+
# left intact, and globals are never written back into any header.
|
|
62
|
+
# Returns the list of names newly added to config.groups.
|
|
63
|
+
def globalize(config)
|
|
64
|
+
seen = Hash.new { |h, k| h[k] = { count: 0, name: nil, hash: nil } }
|
|
65
|
+
config.ledgers.each_value do |entry|
|
|
66
|
+
groups = entry["groups"]
|
|
67
|
+
next unless groups.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
groups.each do |name, hash|
|
|
70
|
+
next unless hash.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
slot = seen[Groups.canonical(name, hash)]
|
|
73
|
+
slot[:count] += 1
|
|
74
|
+
slot[:name] = name
|
|
75
|
+
slot[:hash] = hash
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Only touch config.groups when there is a shared def — avoids the lazy
|
|
80
|
+
# accessor materializing an empty "groups": {} on every sync.
|
|
81
|
+
candidates = seen.each_value.select { |slot| slot[:count] >= 2 }
|
|
82
|
+
return [] if candidates.empty?
|
|
83
|
+
|
|
84
|
+
promoted = []
|
|
85
|
+
candidates.each do |slot|
|
|
86
|
+
next if config.groups[slot[:name]] == slot[:hash]
|
|
87
|
+
|
|
88
|
+
config.groups[slot[:name]] = slot[:hash]
|
|
89
|
+
promoted << slot[:name]
|
|
90
|
+
end
|
|
91
|
+
promoted
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Write config's managed fields into the notes header. Returns true when the
|
|
95
|
+
# file content actually changed (and stamps synced_at to the new mtime).
|
|
96
|
+
def config_to_notes(path, entry, fm_full, spltty, body)
|
|
97
|
+
cfg_fields = MANAGED.each_with_object({}) do |k, h|
|
|
98
|
+
v = entry[k]
|
|
99
|
+
h[k] = v unless v.nil? || v.to_s.empty?
|
|
100
|
+
end
|
|
101
|
+
return false if cfg_fields.empty? && spltty.empty?
|
|
102
|
+
|
|
103
|
+
new_spltty = spltty.dup
|
|
104
|
+
cfg_fields.each { |k, v| new_spltty[k] = v }
|
|
105
|
+
new_fm = fm_full.dup
|
|
106
|
+
if new_spltty.empty?
|
|
107
|
+
new_fm.delete("spltty")
|
|
108
|
+
else
|
|
109
|
+
new_fm["spltty"] = new_spltty
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
old_raw = File.read(path)
|
|
113
|
+
new_raw = render(new_fm, body)
|
|
114
|
+
return false if new_raw == old_raw
|
|
115
|
+
|
|
116
|
+
File.write(path, new_raw)
|
|
117
|
+
entry["synced_at"] = File.mtime(path).utc.iso8601(9)
|
|
118
|
+
true
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def render(frontmatter, body)
|
|
122
|
+
if frontmatter.nil? || frontmatter.empty?
|
|
123
|
+
body
|
|
124
|
+
else
|
|
125
|
+
"#{YAML.dump(frontmatter)}#{Notes::FENCE}\n#{body}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_time(str)
|
|
130
|
+
return nil if str.nil? || str.to_s.empty?
|
|
131
|
+
|
|
132
|
+
Time.iso8601(str.to_s)
|
|
133
|
+
rescue ArgumentError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
# Minimal stdlib prompts. Prompts render on stderr so stdout stays clean for
|
|
5
|
+
# the final confirmation line; input is read from stdin.
|
|
6
|
+
module Prompt
|
|
7
|
+
class Abort < StandardError; end
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Ask for a value. Returns the entered string (or the default when the user
|
|
12
|
+
# just hits enter). With required: true, keeps asking until non-empty.
|
|
13
|
+
# At EOF (piped/no tty) it falls back to the default, or raises when a
|
|
14
|
+
# required value has no default.
|
|
15
|
+
def ask(label, default: nil, required: false)
|
|
16
|
+
loop do
|
|
17
|
+
suffix = default && !default.to_s.empty? ? " [#{default}]" : ""
|
|
18
|
+
$stderr.print "#{label}#{suffix}: "
|
|
19
|
+
raw = $stdin.gets
|
|
20
|
+
if raw.nil?
|
|
21
|
+
return default.to_s if default
|
|
22
|
+
raise Abort, "no input available for required field: #{label}" if required
|
|
23
|
+
|
|
24
|
+
return ""
|
|
25
|
+
end
|
|
26
|
+
answer = raw.strip
|
|
27
|
+
answer = default.to_s if answer.empty? && default
|
|
28
|
+
return answer unless required && answer.empty?
|
|
29
|
+
|
|
30
|
+
$stderr.puts " (required)"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Pick one of `options` (an array of labels). Accepts the 1-based number or
|
|
35
|
+
# the label itself (case-insensitive). Returns the 0-based index. Empty
|
|
36
|
+
# input or EOF selects `default` (which is 1-based, like what's displayed).
|
|
37
|
+
def choose(label, options, default: 1)
|
|
38
|
+
raise ArgumentError, "choose needs at least one option" if options.empty?
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
options.each_with_index { |opt, i| $stderr.puts " #{i + 1}) #{opt}" }
|
|
42
|
+
$stderr.print "#{label} [#{default}]: "
|
|
43
|
+
raw = $stdin.gets
|
|
44
|
+
return default - 1 if raw.nil?
|
|
45
|
+
|
|
46
|
+
answer = raw.strip
|
|
47
|
+
return default - 1 if answer.empty?
|
|
48
|
+
|
|
49
|
+
index =
|
|
50
|
+
if answer.match?(/\A\d+\z/)
|
|
51
|
+
i = answer.to_i - 1
|
|
52
|
+
i if i >= 0 && i < options.length
|
|
53
|
+
else
|
|
54
|
+
options.index { |o| o.to_s.casecmp?(answer) }
|
|
55
|
+
end
|
|
56
|
+
return index if index
|
|
57
|
+
|
|
58
|
+
$stderr.puts " (enter a number between 1 and #{options.length}, or the name)"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Yes/no confirmation. Default answer used on empty input or EOF.
|
|
63
|
+
def confirm(label, default: true)
|
|
64
|
+
suffix = default ? "Y/n" : "y/N"
|
|
65
|
+
$stderr.print "#{label} [#{suffix}]: "
|
|
66
|
+
raw = $stdin.gets
|
|
67
|
+
return default if raw.nil?
|
|
68
|
+
|
|
69
|
+
answer = raw.strip.downcase
|
|
70
|
+
return default if answer.empty?
|
|
71
|
+
|
|
72
|
+
answer.start_with?("y")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
# Parse a markdown ledger table and format/append a single aligned row.
|
|
5
|
+
#
|
|
6
|
+
# Parsing (shared with the Totals reader): only lines starting with "|" are
|
|
7
|
+
# considered, the separator row is detected by its character set, the first
|
|
8
|
+
# table row is the header, and columns are addressed by header name. Writing
|
|
9
|
+
# only appends the new row — existing rows are never rewritten.
|
|
10
|
+
module Table
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# "| a | b |" -> ["a", "b"]
|
|
14
|
+
def row_cells(line)
|
|
15
|
+
line.strip.sub(/\A\|/, "").sub(/\|\z/, "").split("|", -1).map(&:strip)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A separator row is made up solely of -, :, and spaces (e.g. "|---|--:|").
|
|
19
|
+
def separator?(cells)
|
|
20
|
+
!cells.empty? && cells.all? { |c| c.gsub(/[:\-\s]/, "").empty? }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns a hash describing the table:
|
|
24
|
+
# :header => array of column names
|
|
25
|
+
# :align => array of :left/:right per column
|
|
26
|
+
# :widths => array of existing max content width per column
|
|
27
|
+
# :anchor_idx => index (in text.split("\n", -1)) of the last "|" line
|
|
28
|
+
def parse(text)
|
|
29
|
+
lines = text.split("\n", -1)
|
|
30
|
+
header = nil
|
|
31
|
+
sep_cells = nil
|
|
32
|
+
widths = nil
|
|
33
|
+
anchor_idx = nil
|
|
34
|
+
|
|
35
|
+
lines.each_with_index do |line, i|
|
|
36
|
+
next unless line.strip.start_with?("|")
|
|
37
|
+
|
|
38
|
+
anchor_idx = i
|
|
39
|
+
cells = row_cells(line)
|
|
40
|
+
|
|
41
|
+
if separator?(cells)
|
|
42
|
+
sep_cells ||= cells if header
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if header.nil?
|
|
47
|
+
header = cells
|
|
48
|
+
widths = cells.map(&:length)
|
|
49
|
+
else
|
|
50
|
+
cells.each_with_index do |c, j|
|
|
51
|
+
next if j >= widths.length
|
|
52
|
+
|
|
53
|
+
widths[j] = [widths[j], c.length].max
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "no markdown table found" if header.nil?
|
|
59
|
+
|
|
60
|
+
{ header: header, align: alignments(header, sep_cells), widths: widths, anchor_idx: anchor_idx }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def alignments(header, sep_cells)
|
|
64
|
+
header.each_index.map do |j|
|
|
65
|
+
sc = sep_cells && sep_cells[j] ? sep_cells[j].strip : nil
|
|
66
|
+
if sc && sc.end_with?(":") && !sc.start_with?(":")
|
|
67
|
+
:right
|
|
68
|
+
elsif ["Value (R$)", "Orig. Value"].include?(header[j])
|
|
69
|
+
:right # fallback when no separator (e.g. freshly scaffolded file)
|
|
70
|
+
else
|
|
71
|
+
:left
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build the markdown row string for `values` (Hash: header name => string),
|
|
77
|
+
# padded to the existing column widths so it lines up with prior rows.
|
|
78
|
+
def format_row(parsed, values)
|
|
79
|
+
cells = parsed[:header].each_index.map do |j|
|
|
80
|
+
name = parsed[:header][j]
|
|
81
|
+
content = values.fetch(name, values[name.to_sym]).to_s
|
|
82
|
+
width = [parsed[:widths][j], content.length].max
|
|
83
|
+
parsed[:align][j] == :right ? content.rjust(width) : content.ljust(width)
|
|
84
|
+
end
|
|
85
|
+
"| #{cells.join(' | ')} |"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Insert `row` right after the last table line, preserving trailing newlines.
|
|
89
|
+
def insert_row(text, parsed, row)
|
|
90
|
+
lines = text.split("\n", -1)
|
|
91
|
+
lines.insert(parsed[:anchor_idx] + 1, row)
|
|
92
|
+
lines.join("\n")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|