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.
@@ -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