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,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
module Commands
|
|
7
|
+
# `spltty add` — append one expense row to a ledger, applying payment-method
|
|
8
|
+
# defaults from config and offering to create the ledger/file if missing.
|
|
9
|
+
class Add < Dry::CLI::Command
|
|
10
|
+
desc "Append an expense entry to a ledger"
|
|
11
|
+
|
|
12
|
+
argument :title, required: false, desc: "Expense description (positional, e.g. \"Leite 1L\")"
|
|
13
|
+
|
|
14
|
+
option :ledger, aliases: ["-l"], desc: "Ledger name (default: config default_ledger)"
|
|
15
|
+
option :date, aliases: ["-d"], desc: "Date: DD-MM or DD/MM (this year), DD/MM/YYYY, MM-DD-YYYY, or YYYY-MM-DD (default: today or card bill day)"
|
|
16
|
+
option :value, aliases: ["-v"], desc: "Amount in R$ (e.g. 129.90)"
|
|
17
|
+
option :paid_by, aliases: ["-p"], desc: "Who fronted the money (Thiago/Camila/...)"
|
|
18
|
+
option :method, aliases: ["-m"], desc: "Payment method: full name, its slug (e.g. mc-3278), or a one-off like cash/Pix"
|
|
19
|
+
option :responsible, aliases: ["-r"], desc: "Who bears the cost: a split-group name (Both/...) or a person (Thiago/Camila/...)"
|
|
20
|
+
option :paid_responsible, aliases: ["-pr"], desc: "Shortcut: set both Paid By and Responsible (overridden by -p/-r)"
|
|
21
|
+
option :source, aliases: ["-s"], desc: "Provenance (text/audio/filename)", default: "text"
|
|
22
|
+
option :orig_value, aliases: ["-o"], desc: "Original amount (montreal schema only)"
|
|
23
|
+
option :currency, aliases: ["-c"], desc: "Original currency, e.g. CAD (montreal schema only)"
|
|
24
|
+
option :create_ledger, type: :boolean, default: false,
|
|
25
|
+
desc: "Create --ledger when it does not exist yet (required with --yes)"
|
|
26
|
+
option :yes, aliases: ["-y"], type: :boolean, default: false,
|
|
27
|
+
desc: "Skip prompts/confirmation (non-interactive)"
|
|
28
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: the workspace's .spltty/config.json)"
|
|
29
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
30
|
+
|
|
31
|
+
example [
|
|
32
|
+
%("Leite 1L" -l Casa -v 14.20 -p Thiago -m cash),
|
|
33
|
+
%("Coffee" -l CASA -v 12.90 -m "Mastercard 9759" -y),
|
|
34
|
+
%("Metro" -l MONTREAL -o 3.75 -c CAD -v 14.20 -p Camila -m Wise -y),
|
|
35
|
+
" # fully interactive (prompts for what's missing)",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
def call(**opts)
|
|
39
|
+
AddRunner.new(opts).run
|
|
40
|
+
rescue Prompt::Abort, Config::Error, ArgumentError => e
|
|
41
|
+
warn "spltty add: #{e.message}"
|
|
42
|
+
exit 1
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Orchestrates the add flow. Extracted from the command so it is unit-testable.
|
|
47
|
+
class AddRunner
|
|
48
|
+
def initialize(opts)
|
|
49
|
+
@opts = opts
|
|
50
|
+
@interactive = !opts[:yes]
|
|
51
|
+
@dirty = false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run
|
|
55
|
+
@config = SplttyCLI.load_config(@opts)
|
|
56
|
+
print_discovery(Discovery.sync(@config))
|
|
57
|
+
|
|
58
|
+
name, entry = resolve_ledger
|
|
59
|
+
montreal = Ledger.montreal?(entry)
|
|
60
|
+
|
|
61
|
+
method_name, method_cfg = resolve_method
|
|
62
|
+
date = resolve_date(method_cfg)
|
|
63
|
+
|
|
64
|
+
values = gather(entry, montreal, method_name, method_cfg, date)
|
|
65
|
+
|
|
66
|
+
path = Ledger.target_path(@config, name, entry, date)
|
|
67
|
+
ensure_file(name, entry, path, date)
|
|
68
|
+
|
|
69
|
+
text = File.read(path)
|
|
70
|
+
parsed = Table.parse(text)
|
|
71
|
+
row = Table.format_row(parsed, values)
|
|
72
|
+
|
|
73
|
+
preview(name, path, row)
|
|
74
|
+
if @interactive && !Prompt.confirm("Append this row?", default: true)
|
|
75
|
+
puts "Aborted — nothing written."
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
File.write(path, Table.insert_row(text, parsed, row))
|
|
80
|
+
@config.save if @dirty
|
|
81
|
+
puts "Appended to #{path}"
|
|
82
|
+
puts row
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def resolve_ledger
|
|
88
|
+
name = (@opts[:ledger] || @config.default_ledger).to_s
|
|
89
|
+
name = Prompt.ask("Ledger", required: true) if name.empty?
|
|
90
|
+
key, entry = Ledger.config_entry(@config, name)
|
|
91
|
+
return [key, entry] if entry
|
|
92
|
+
|
|
93
|
+
resolve_unknown_ledger(key)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# An unknown ledger name is far more often a typo (`-l CAS`) than a new
|
|
97
|
+
# account, and a typo silently scaffolds a junk ledger that then shows up
|
|
98
|
+
# in every totals run. So: offer to create it, but list the existing
|
|
99
|
+
# ledgers alongside so the slip can be corrected in place.
|
|
100
|
+
# Returns [name, entry].
|
|
101
|
+
def resolve_unknown_ledger(name)
|
|
102
|
+
return [name, create_ledger(name)] if @opts[:create_ledger]
|
|
103
|
+
|
|
104
|
+
known = @config.ledgers.keys
|
|
105
|
+
unless @interactive
|
|
106
|
+
hint = known.empty? ? "no ledgers exist yet" : "known ledgers: #{known.join(', ')}"
|
|
107
|
+
raise Prompt::Abort, "unknown ledger #{name.inspect} (#{hint}) — " \
|
|
108
|
+
"pass --create-ledger to create it"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
warn "Ledger #{name.inspect} is not known yet."
|
|
112
|
+
if known.empty?
|
|
113
|
+
raise Prompt::Abort, "ledger #{name.inspect} does not exist" unless Prompt.confirm("Create it?", default: true)
|
|
114
|
+
|
|
115
|
+
return [name, create_ledger(name)]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
choice = Prompt.choose("Which ledger?", ["Create a new ledger #{name.inspect}"] + known, default: 1)
|
|
119
|
+
return [name, create_ledger(name)] if choice.zero?
|
|
120
|
+
|
|
121
|
+
key = known[choice - 1]
|
|
122
|
+
[key, @config.ledgers[key]]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Register a new ledger in config (the entries file itself is scaffolded
|
|
126
|
+
# later, by ensure_file). Monthly is the house default, matching
|
|
127
|
+
# `spltty install`.
|
|
128
|
+
def create_ledger(name)
|
|
129
|
+
type = "monthly"
|
|
130
|
+
schema = "standard"
|
|
131
|
+
if @interactive
|
|
132
|
+
type = Prompt.confirm("Monthly (one file per month)? (n = single file)", default: true) ? "monthly" : "single"
|
|
133
|
+
schema = Prompt.confirm("Multi-currency (extra Orig. Value + Cur. columns)?", default: false) ? "montreal" : "standard"
|
|
134
|
+
end
|
|
135
|
+
entry = { "type" => type, "schema" => schema }
|
|
136
|
+
type == "monthly" ? entry["dir"] = name : entry["file"] = "#{name}.ledger.md"
|
|
137
|
+
@config.ledgers[name] = entry
|
|
138
|
+
@dirty = true
|
|
139
|
+
warn "Creating ledger #{name.inspect} (#{type})."
|
|
140
|
+
entry
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def resolve_method
|
|
144
|
+
name = @opts[:method]
|
|
145
|
+
name = Prompt.ask("Payment Method") if missing?(name) && @interactive
|
|
146
|
+
method_name, method_cfg = @config.payment_method(name.to_s)
|
|
147
|
+
[method_name.to_s, method_cfg]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve_date(method_cfg)
|
|
151
|
+
return parse_date(@opts[:date].to_s) unless missing?(@opts[:date])
|
|
152
|
+
|
|
153
|
+
today = Date.today
|
|
154
|
+
return bill_date(today, method_cfg["bill_day"]) if method_cfg && method_cfg["bill_day"]
|
|
155
|
+
|
|
156
|
+
today
|
|
157
|
+
rescue Date::Error, ArgumentError
|
|
158
|
+
raise Prompt::Abort, "invalid --date: #{@opts[:date].inspect}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Flexible date input:
|
|
162
|
+
# YYYY-MM-DD -> ISO (as stored)
|
|
163
|
+
# MM-DD-YYYY -> American (dash + year)
|
|
164
|
+
# DD-MM / DD/MM -> day/month in the current year
|
|
165
|
+
# DD/MM/YYYY -> day/month/year
|
|
166
|
+
# Anything else falls back to Ruby's Date.parse.
|
|
167
|
+
def parse_date(str)
|
|
168
|
+
s = str.strip
|
|
169
|
+
year = Date.today.year
|
|
170
|
+
case s
|
|
171
|
+
when /\A(\d{4})-(\d{1,2})-(\d{1,2})\z/ then Date.new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
|
172
|
+
when /\A(\d{1,2})-(\d{1,2})-(\d{4})\z/ then Date.new(::Regexp.last_match(3).to_i, ::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i)
|
|
173
|
+
when %r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z} then Date.new(::Regexp.last_match(3).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i)
|
|
174
|
+
when /\A(\d{1,2})-(\d{1,2})\z/ then Date.new(year, ::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i)
|
|
175
|
+
when %r{\A(\d{1,2})/(\d{1,2})\z} then Date.new(year, ::Regexp.last_match(2).to_i, ::Regexp.last_match(1).to_i)
|
|
176
|
+
else Date.parse(s)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def bill_date(ref, day)
|
|
181
|
+
return Date.new(ref.year, ref.month, -1) if day.to_s == "last"
|
|
182
|
+
|
|
183
|
+
Date.new(ref.year, ref.month, day.to_i)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Build the row. Fields supplied by flag (or a payment-method / ledger
|
|
187
|
+
# default) are used as-is; only genuinely missing required fields prompt in
|
|
188
|
+
# interactive mode. A confirmation step follows in `run`.
|
|
189
|
+
def gather(entry, montreal, method_name, method_cfg, date)
|
|
190
|
+
values = { "Date" => date.strftime(@config.date_format) }
|
|
191
|
+
|
|
192
|
+
values["Title"] = need(:title, "Title")
|
|
193
|
+
|
|
194
|
+
if montreal
|
|
195
|
+
values["Orig. Value"] = money(need(:orig_value, "Orig. Value"))
|
|
196
|
+
cur = @opts[:currency] || (entry && entry["default_currency"]) || "CAD"
|
|
197
|
+
values["Cur."] = cur.to_s.upcase
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
values["Value (R$)"] = money(need(:value, "Value (R$)"))
|
|
201
|
+
|
|
202
|
+
both = @opts[:paid_responsible]
|
|
203
|
+
|
|
204
|
+
paid_by = @opts[:paid_by] || both || method_cfg&.dig("paid_by")
|
|
205
|
+
paid_by = Prompt.ask("Paid By", required: true) if missing?(paid_by) && @interactive
|
|
206
|
+
values["Paid By"] = require_value!(paid_by, "Paid By (--paid-by / --paid-responsible)")
|
|
207
|
+
|
|
208
|
+
values["Payment Method"] = method_name
|
|
209
|
+
|
|
210
|
+
values["Responsible"] = @opts[:responsible] || both || method_cfg&.dig("responsible") ||
|
|
211
|
+
(entry && entry["default_responsible"]) || "Both"
|
|
212
|
+
|
|
213
|
+
values["Source"] = @opts[:source] || "text"
|
|
214
|
+
|
|
215
|
+
values
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def missing?(val)
|
|
219
|
+
val.nil? || val.to_s.strip.empty?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Return a required field's value: use the flag if present, otherwise prompt
|
|
223
|
+
# (interactive) or error (non-interactive).
|
|
224
|
+
def need(key, label)
|
|
225
|
+
val = @opts[key]
|
|
226
|
+
return val unless missing?(val)
|
|
227
|
+
return Prompt.ask(label, required: true) if @interactive
|
|
228
|
+
|
|
229
|
+
raise Prompt::Abort, "missing required option --#{key.to_s.tr('_', '-')}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Normalize an amount string to two decimals. Accepts "12.5", "12,50", and
|
|
233
|
+
# "1.234,56" (dot thousands + comma decimal).
|
|
234
|
+
def money(str)
|
|
235
|
+
s = str.to_s.strip
|
|
236
|
+
raise Prompt::Abort, "missing amount" if s.empty?
|
|
237
|
+
|
|
238
|
+
s = s.include?(".") && s.include?(",") ? s.delete(".").tr(",", ".") : s.tr(",", ".")
|
|
239
|
+
format("%.2f", Float(s))
|
|
240
|
+
rescue ArgumentError
|
|
241
|
+
raise Prompt::Abort, "invalid amount: #{str.inspect}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def require_value!(val, label)
|
|
245
|
+
raise Prompt::Abort, "missing #{label}" if val.nil? || val.to_s.strip.empty?
|
|
246
|
+
|
|
247
|
+
val
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def ensure_file(name, entry, path, date)
|
|
251
|
+
return if File.exist?(path)
|
|
252
|
+
|
|
253
|
+
if @interactive && !Prompt.confirm("File #{path} doesn't exist. Create it?", default: true)
|
|
254
|
+
raise Prompt::Abort, "target file does not exist: #{path}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
Ledger.scaffold(path, Ledger.title(name, entry, date),
|
|
258
|
+
Ledger.notes_pointer(name, entry), Ledger.schema(entry))
|
|
259
|
+
warn "Created #{path}"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def preview(name, path, row)
|
|
263
|
+
warn ""
|
|
264
|
+
warn "Ledger : #{name} (#{path})"
|
|
265
|
+
warn "Row : #{row}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def print_discovery(result)
|
|
269
|
+
warn "Discovered ledgers added to config: #{result[:added].join(', ')}" if result[:added].any?
|
|
270
|
+
warn "Ledgers in config but missing on disk: #{result[:missing].join(', ')}" if result[:missing].any?
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty groups` — list global split groups and each ledger's own groups.
|
|
6
|
+
class Groups < Dry::CLI::Command
|
|
7
|
+
desc "List split groups (global + per-ledger)"
|
|
8
|
+
|
|
9
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
10
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
11
|
+
|
|
12
|
+
def call(**opts)
|
|
13
|
+
config = SplttyCLI.load_config(opts)
|
|
14
|
+
|
|
15
|
+
printed = false
|
|
16
|
+
unless config.groups.empty?
|
|
17
|
+
puts "Global groups:"
|
|
18
|
+
print_groups(config.groups)
|
|
19
|
+
printed = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
config.ledgers.each do |name, entry|
|
|
23
|
+
groups = entry["groups"]
|
|
24
|
+
next unless groups.is_a?(Hash) && !groups.empty?
|
|
25
|
+
|
|
26
|
+
puts "#{name}:"
|
|
27
|
+
print_groups(groups)
|
|
28
|
+
printed = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts "No split groups defined." unless printed
|
|
32
|
+
rescue Config::Error => e
|
|
33
|
+
warn "spltty groups: #{e.message}"
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def print_groups(groups)
|
|
40
|
+
width = groups.keys.map(&:length).max
|
|
41
|
+
groups.each do |name, hash|
|
|
42
|
+
puts format(" %-#{width}s %s", name, SplttyCLI::Groups.format(hash))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SplttyCLI
|
|
6
|
+
module Commands
|
|
7
|
+
# `spltty groups add` — define or update a split group. For a ledger the
|
|
8
|
+
# group is written into that ledger's notes header (the source of truth) and
|
|
9
|
+
# then reconciled into config via NotesSync. With --global it is written
|
|
10
|
+
# straight into the config's global groups (globals never live in a header).
|
|
11
|
+
class GroupsAdd < Dry::CLI::Command
|
|
12
|
+
desc "Add or update a split group (in a ledger header, or --global)"
|
|
13
|
+
|
|
14
|
+
argument :name, required: true, desc: "Group name (e.g. \"Both\")"
|
|
15
|
+
|
|
16
|
+
option :split, aliases: ["-s"], desc: "Participants as NAME:PCT,NAME:PCT (e.g. Thiago:70,Camila:30)"
|
|
17
|
+
option :ledger, aliases: ["-l"], desc: "Ledger whose header owns this group"
|
|
18
|
+
option :global, aliases: ["-g"], type: :boolean, default: false, desc: "Store as a global group instead"
|
|
19
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
20
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
21
|
+
|
|
22
|
+
example [
|
|
23
|
+
%(Both -s Thiago:70,Camila:30 -l CASA),
|
|
24
|
+
%(Both -s Thiago:50,Camila:50 --global),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def call(name:, **opts)
|
|
28
|
+
config = SplttyCLI.load_config(opts)
|
|
29
|
+
hash = SplttyCLI::Groups.parse_split(opts[:split])
|
|
30
|
+
SplttyCLI::Groups.validate(name, hash)
|
|
31
|
+
|
|
32
|
+
if opts[:global]
|
|
33
|
+
config.groups[name] = hash
|
|
34
|
+
config.save
|
|
35
|
+
puts "Added global group #{name.inspect}: #{SplttyCLI::Groups.format(hash)}"
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
raise Config::Error, "specify --ledger NAME or --global" if blank?(opts[:ledger])
|
|
40
|
+
|
|
41
|
+
Discovery.sync(config)
|
|
42
|
+
key = config.ledger_key(opts[:ledger]) or
|
|
43
|
+
raise Config::Error, "unknown ledger #{opts[:ledger].inspect}"
|
|
44
|
+
entry = config.ledgers[key]
|
|
45
|
+
path = Notes.path_for(config.accounts_dir, key, entry)
|
|
46
|
+
raise Config::Error, "no notes file for #{key} at #{path}" unless File.exist?(path)
|
|
47
|
+
|
|
48
|
+
parsed = Notes.parse(path)
|
|
49
|
+
fm = parsed[:frontmatter]
|
|
50
|
+
spltty = (fm["spltty"] ||= {})
|
|
51
|
+
groups = (spltty["groups"] ||= {})
|
|
52
|
+
groups[name] = hash
|
|
53
|
+
Notes.write(path, fm, parsed[:body])
|
|
54
|
+
|
|
55
|
+
NotesSync.run(config)
|
|
56
|
+
config.save
|
|
57
|
+
puts "Added group #{name.inspect} to #{key}: #{SplttyCLI::Groups.format(hash)}"
|
|
58
|
+
rescue ArgumentError => e
|
|
59
|
+
warn "spltty groups add: #{e.message}"
|
|
60
|
+
exit 1
|
|
61
|
+
rescue Config::Error => e
|
|
62
|
+
warn "spltty groups add: #{e.message}"
|
|
63
|
+
exit 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def blank?(value)
|
|
69
|
+
value.nil? || value.to_s.strip.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty groups rm` — remove a split group from a ledger header (source of
|
|
6
|
+
# truth, then reconciled via NotesSync) or from the global groups (--global).
|
|
7
|
+
class GroupsRm < Dry::CLI::Command
|
|
8
|
+
desc "Remove a split group (from a ledger header, or --global)"
|
|
9
|
+
|
|
10
|
+
argument :name, required: true, desc: "Group name to remove"
|
|
11
|
+
|
|
12
|
+
option :ledger, aliases: ["-l"], desc: "Ledger whose header owns this group"
|
|
13
|
+
option :global, aliases: ["-g"], type: :boolean, default: false, desc: "Remove from the global groups instead"
|
|
14
|
+
option :config, aliases: ["-C"], desc: "Path to config.json (default: cli/config.json)"
|
|
15
|
+
option :accounts_dir, aliases: ["-A"], desc: "Override the accounts directory"
|
|
16
|
+
|
|
17
|
+
def call(name:, **opts)
|
|
18
|
+
config = SplttyCLI.load_config(opts)
|
|
19
|
+
|
|
20
|
+
if opts[:global]
|
|
21
|
+
removed = config.groups.delete(name)
|
|
22
|
+
config.save
|
|
23
|
+
puts removed ? "Removed global group #{name.inspect}" : "No global group #{name.inspect}"
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
raise Config::Error, "specify --ledger NAME or --global" if blank?(opts[:ledger])
|
|
28
|
+
|
|
29
|
+
Discovery.sync(config)
|
|
30
|
+
key = config.ledger_key(opts[:ledger]) or
|
|
31
|
+
raise Config::Error, "unknown ledger #{opts[:ledger].inspect}"
|
|
32
|
+
entry = config.ledgers[key]
|
|
33
|
+
path = Notes.path_for(config.accounts_dir, key, entry)
|
|
34
|
+
raise Config::Error, "no notes file for #{key} at #{path}" unless File.exist?(path)
|
|
35
|
+
|
|
36
|
+
parsed = Notes.parse(path)
|
|
37
|
+
groups = parsed[:frontmatter].dig("spltty", "groups")
|
|
38
|
+
unless groups.is_a?(Hash) && groups.key?(name)
|
|
39
|
+
puts "No group #{name.inspect} in #{key}"
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
groups.delete(name)
|
|
44
|
+
if groups.empty?
|
|
45
|
+
parsed[:frontmatter]["spltty"].delete("groups")
|
|
46
|
+
parsed[:frontmatter].delete("spltty") if parsed[:frontmatter]["spltty"].empty?
|
|
47
|
+
end
|
|
48
|
+
Notes.write(path, parsed[:frontmatter], parsed[:body])
|
|
49
|
+
|
|
50
|
+
# Apply the deletion to config directly: when the header's last group is
|
|
51
|
+
# removed the "groups" key vanishes, and NotesSync's absent-key guard
|
|
52
|
+
# would otherwise leave the stale copy in config.
|
|
53
|
+
if entry["groups"].is_a?(Hash)
|
|
54
|
+
entry["groups"].delete(name)
|
|
55
|
+
entry.delete("groups") if entry["groups"].empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
NotesSync.run(config)
|
|
59
|
+
config.save
|
|
60
|
+
puts "Removed group #{name.inspect} from #{key}"
|
|
61
|
+
rescue Config::Error => e
|
|
62
|
+
warn "spltty groups rm: #{e.message}"
|
|
63
|
+
exit 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def blank?(value)
|
|
69
|
+
value.nil? || value.to_s.strip.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SplttyCLI
|
|
4
|
+
module Commands
|
|
5
|
+
# `spltty help` — print every command with its aliases and options, read
|
|
6
|
+
# straight from each command's dry-cli metadata.
|
|
7
|
+
class Help < Dry::CLI::Command
|
|
8
|
+
desc "Show all commands and their options"
|
|
9
|
+
|
|
10
|
+
def call(**)
|
|
11
|
+
puts "spltty — append entries to the expense ledgers under accounts/"
|
|
12
|
+
puts
|
|
13
|
+
puts "Usage:"
|
|
14
|
+
puts " spltty COMMAND [options]"
|
|
15
|
+
puts
|
|
16
|
+
puts "Commands:"
|
|
17
|
+
SplttyCLI::COMMAND_SPECS.each { |spec| print_command(spec) }
|
|
18
|
+
puts "Run `spltty COMMAND --help` for details on a single command."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def print_command(spec)
|
|
24
|
+
klass = spec[:klass]
|
|
25
|
+
puts
|
|
26
|
+
puts " #{spec[:name]}"
|
|
27
|
+
puts " #{klass.description}" if klass.description
|
|
28
|
+
klass.arguments.each do |arg|
|
|
29
|
+
label = "<#{arg.name}>#{arg.required? ? '' : ' (optional)'}"
|
|
30
|
+
puts " #{label.ljust(32)} #{arg.desc}"
|
|
31
|
+
end
|
|
32
|
+
klass.options.each do |opt|
|
|
33
|
+
puts " #{flag(opt).ljust(32)} #{opt.desc}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def flag(opt)
|
|
38
|
+
name = "--#{opt.name.to_s.tr('_', '-')}"
|
|
39
|
+
name += ", #{opt.aliases.join(', ')}" unless opt.aliases.empty?
|
|
40
|
+
name
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|