rvgp 0.3.2
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/.gitignore +8 -0
- data/.rubocop.yml +23 -0
- data/LICENSE +504 -0
- data/README.md +223 -0
- data/Rakefile +32 -0
- data/bin/rvgp +8 -0
- data/lib/rvgp/application/config.rb +159 -0
- data/lib/rvgp/application/descendant_registry.rb +122 -0
- data/lib/rvgp/application/status_output.rb +139 -0
- data/lib/rvgp/application.rb +170 -0
- data/lib/rvgp/base/command.rb +457 -0
- data/lib/rvgp/base/grid.rb +531 -0
- data/lib/rvgp/base/reader.rb +29 -0
- data/lib/rvgp/base/reconciler.rb +434 -0
- data/lib/rvgp/base/validation.rb +261 -0
- data/lib/rvgp/commands/cashflow.rb +160 -0
- data/lib/rvgp/commands/grid.rb +70 -0
- data/lib/rvgp/commands/ireconcile.rb +95 -0
- data/lib/rvgp/commands/new_project.rb +296 -0
- data/lib/rvgp/commands/plot.rb +41 -0
- data/lib/rvgp/commands/publish_gsheets.rb +83 -0
- data/lib/rvgp/commands/reconcile.rb +58 -0
- data/lib/rvgp/commands/rotate_year.rb +202 -0
- data/lib/rvgp/commands/validate_journal.rb +59 -0
- data/lib/rvgp/commands/validate_system.rb +44 -0
- data/lib/rvgp/commands.rb +160 -0
- data/lib/rvgp/dashboard.rb +252 -0
- data/lib/rvgp/fakers/fake_feed.rb +245 -0
- data/lib/rvgp/fakers/fake_journal.rb +57 -0
- data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
- data/lib/rvgp/fakers/faker_helpers.rb +25 -0
- data/lib/rvgp/gem.rb +80 -0
- data/lib/rvgp/journal/commodity.rb +453 -0
- data/lib/rvgp/journal/complex_commodity.rb +214 -0
- data/lib/rvgp/journal/currency.rb +101 -0
- data/lib/rvgp/journal/journal.rb +141 -0
- data/lib/rvgp/journal/posting.rb +156 -0
- data/lib/rvgp/journal/pricer.rb +267 -0
- data/lib/rvgp/journal.rb +24 -0
- data/lib/rvgp/plot/gnuplot.rb +478 -0
- data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
- data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
- data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
- data/lib/rvgp/plot.rb +293 -0
- data/lib/rvgp/pta/hledger.rb +237 -0
- data/lib/rvgp/pta/ledger.rb +308 -0
- data/lib/rvgp/pta.rb +311 -0
- data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
- data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
- data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
- data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
- data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
- data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
- data/lib/rvgp/utilities/grid_query.rb +190 -0
- data/lib/rvgp/utilities/yaml.rb +131 -0
- data/lib/rvgp/utilities.rb +44 -0
- data/lib/rvgp/validations/balance_validation.rb +68 -0
- data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
- data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
- data/lib/rvgp.rb +66 -0
- data/resources/README.MD/2022-cashflow-google.png +0 -0
- data/resources/README.MD/2022-cashflow.png +0 -0
- data/resources/README.MD/all-wealth-growth-google.png +0 -0
- data/resources/README.MD/all-wealth-growth.png +0 -0
- data/resources/gnuplot/default.yml +80 -0
- data/resources/i18n/en.yml +192 -0
- data/resources/iso-4217-currencies.json +171 -0
- data/resources/skel/Rakefile +5 -0
- data/resources/skel/app/grids/cashflow_grid.rb +27 -0
- data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
- data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
- data/resources/skel/app/plots/cashflow.yml +33 -0
- data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
- data/resources/skel/app/plots/wealth-growth.yml +20 -0
- data/resources/skel/config/csv-format-acme-checking.yml +9 -0
- data/resources/skel/config/google-secrets.yml +5 -0
- data/resources/skel/config/rvgp.yml +0 -0
- data/resources/skel/journals/prices.db +0 -0
- data/rvgp.gemspec +6 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
- data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
- data/test/test_command_base.rb +61 -0
- data/test/test_commodity.rb +270 -0
- data/test/test_csv_reconciler.rb +60 -0
- data/test/test_currency.rb +24 -0
- data/test/test_fake_feed.rb +228 -0
- data/test/test_fake_journal.rb +98 -0
- data/test/test_fake_reconciler.rb +60 -0
- data/test/test_journal_parse.rb +545 -0
- data/test/test_ledger.rb +102 -0
- data/test/test_plot.rb +133 -0
- data/test/test_posting.rb +50 -0
- data/test/test_pricer.rb +139 -0
- data/test/test_pta_adapter.rb +575 -0
- data/test/test_utilities.rb +45 -0
- metadata +268 -0
data/lib/rvgp/plot.rb
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'plot/gnuplot'
|
|
4
|
+
|
|
5
|
+
module RVGP
|
|
6
|
+
# This class assembles grids into a series of plots, given a plot specification
|
|
7
|
+
# yaml. Once a grid is assembled, it's dispatched to a driver ({GoogleDrive} or {Gnuplot})
|
|
8
|
+
# for rendering.
|
|
9
|
+
# Here's an example plot specification, included in the default project build as
|
|
10
|
+
# {https://github.com/brighton36/rvgp/blob/main/resources/skel/app/plots/wealth-growth.yml wealth-growth.yml}, as
|
|
11
|
+
# created by the new_project command:
|
|
12
|
+
# ```
|
|
13
|
+
# title: "Wealth Growth (%{year})"
|
|
14
|
+
# glob: "%{year}-wealth-growth.csv"
|
|
15
|
+
# grid_hacks:
|
|
16
|
+
# store_cell: !!proc >
|
|
17
|
+
# (cell) ? cell.to_f.abs : nil
|
|
18
|
+
# google:
|
|
19
|
+
# chart_type: area
|
|
20
|
+
# axis:
|
|
21
|
+
# left: "Amount"
|
|
22
|
+
# bottom: "Date"
|
|
23
|
+
# gnuplot:
|
|
24
|
+
# chart_type: area
|
|
25
|
+
# domain: monthly
|
|
26
|
+
# axis:
|
|
27
|
+
# left: "Amount"
|
|
28
|
+
# bottom: "Date"
|
|
29
|
+
# additional_lines: |+
|
|
30
|
+
# set xtics scale 0 rotate by 45 offset -1.4,-1.4
|
|
31
|
+
# set key title ' '
|
|
32
|
+
# set style fill transparent solid 0.7 border
|
|
33
|
+
# ```
|
|
34
|
+
#
|
|
35
|
+
# The yaml file is required to have :title and :glob parameters. Additionally,
|
|
36
|
+
# the following parameter groups are supported: :grid_hacks, :gnuplot, and :google.
|
|
37
|
+
#
|
|
38
|
+
# The :gnuplot section of this file, is merged with the contents of
|
|
39
|
+
# {https://github.com/brighton36/rvgp/blob/main/resources/gnuplot/default.yml default.yml}, and passed to the {RVGP::Plot::Gnuplot}
|
|
40
|
+
# constructor. See the {RVGP::Plot::Gnuplot::Plot#initialize} method for more details on what parameters are supported
|
|
41
|
+
# in this section. NOTE: Depending on the kind of chart being specified, some initialize options are specific to the
|
|
42
|
+
# chart being built, and those options will be documented in the constructor for that specific chart. ie:
|
|
43
|
+
# {RVGP::Plot::Gnuplot::AreaChart#initialize} or {RVGP::Plot::Gnuplot::ColumnAndLineChart#initialize}.
|
|
44
|
+
#
|
|
45
|
+
# The :google section of this file, is provided to {RVGP::Plot::GoogleDrive::Sheet#initialize}. See this method for
|
|
46
|
+
# details on supported options.
|
|
47
|
+
#
|
|
48
|
+
# The :grid_hacks section of this file, contains miscellaneous hacks to the dataset. These include: :keystone,
|
|
49
|
+
# :store_cell, :select_rows, :sort_rows_by, :sort_columns_by, :truncate_rows, :switch_rows_columns, and
|
|
50
|
+
# :truncate_columns. These options are documented in: {RVGP::Utilities::GridQuery#initialize} and
|
|
51
|
+
# {RVGP::Utilities::GridQuery#to_grid}.
|
|
52
|
+
#
|
|
53
|
+
# @attr_reader [String] path A path to the location of the input yaml, as provided to #initialize
|
|
54
|
+
# @attr_reader [RVGP::Utilities::Yaml] yaml The yaml object, containing the parameters of this plot
|
|
55
|
+
# @attr_reader [String] glob A string containing wildcards, used to match input grids in the filesystem. This
|
|
56
|
+
# parameter is expected to be found inside the yaml[:glob], and will generally look
|
|
57
|
+
# something like: "%\\{year}-wealth-growth.csv" or
|
|
58
|
+
# "%\\{year}-property-incomes-%\\{property}.csv".
|
|
59
|
+
# The variables which are supported, include 'the year' of a plot, as well as whatever
|
|
60
|
+
# variables are defined in a plot's glob_variants ('property', as was the case
|
|
61
|
+
# above.) glob_variants are output by grids, and detected in the filenames those grids
|
|
62
|
+
# produce by the {RVGP::Plot.glob_variants} method.
|
|
63
|
+
class Plot
|
|
64
|
+
attr_reader :path, :yaml, :glob
|
|
65
|
+
|
|
66
|
+
# The required keys, expected to exist in the plot yaml
|
|
67
|
+
REQUIRED_FIELDS = %i[glob title].freeze
|
|
68
|
+
|
|
69
|
+
# The path to rvgp's 'default' include file search path. Any '!!include' directives encountered in the plot yaml,
|
|
70
|
+
# will search this location for targets.
|
|
71
|
+
GNUPLOT_RESOURCES_PATH = [RVGP::Gem.root, '/resources/gnuplot'].join
|
|
72
|
+
|
|
73
|
+
# This exception is raised when a provided yaml file, is missing required
|
|
74
|
+
# attributes.
|
|
75
|
+
class MissingYamlAttribute < StandardError
|
|
76
|
+
# @!visibility private
|
|
77
|
+
MSG_FORMAT = 'Missing one or more required fields in %<path>s: %<fields>s'
|
|
78
|
+
|
|
79
|
+
def initialize(path, fields)
|
|
80
|
+
super format(MSG_FORMAT, path: path, fields: fields)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# This exception is raised when a provided yaml file, stipulates an invalid
|
|
85
|
+
# {glob} attribute
|
|
86
|
+
class InvalidYamlGlob < StandardError
|
|
87
|
+
# @!visibility private
|
|
88
|
+
MSG_FORMAT = 'Plot file %<path>s is missing a required \'year\' parameter in glob'
|
|
89
|
+
|
|
90
|
+
def initialize(path)
|
|
91
|
+
super format(MSG_FORMAT, path: path)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Create a plot, from a specification yaml
|
|
96
|
+
# @param [String] path The path to a specification yaml
|
|
97
|
+
def initialize(path)
|
|
98
|
+
@path = path
|
|
99
|
+
|
|
100
|
+
@yaml = RVGP::Utilities::Yaml.new path, [RVGP.app.config.project_path, GNUPLOT_RESOURCES_PATH]
|
|
101
|
+
|
|
102
|
+
missing_attrs = REQUIRED_FIELDS.reject { |f| yaml.key? f }
|
|
103
|
+
raise MissingYamlAttribute, yaml.path, missing_attrs unless missing_attrs.empty?
|
|
104
|
+
|
|
105
|
+
@glob = yaml[:glob] if yaml.key? :glob
|
|
106
|
+
raise InvalidYamlGlob, yaml.path unless /%\{year\}/.match glob
|
|
107
|
+
|
|
108
|
+
grids_corpus = Dir[RVGP.app.config.build_path('grids/*')]
|
|
109
|
+
|
|
110
|
+
@variants ||= self.class.glob_variants(glob, grids_corpus) +
|
|
111
|
+
self.class.glob_variants(glob, grids_corpus, year: 'all')
|
|
112
|
+
|
|
113
|
+
@title = yaml[:title] if yaml.key? :title
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# In the case that a name is provided, limit the return to the variant of the provided :name.
|
|
117
|
+
# If no name is provided, all variants in this plot are returned. Variants are determined
|
|
118
|
+
# by the yaml parameter :glob, as applied to the grids found in the build/grids/* path.
|
|
119
|
+
# @param [String] name (nil) Limit the return to this variant, if set
|
|
120
|
+
# @return [Hash<Symbol, Object>] The hash will return name, :pairs, and :files keys,
|
|
121
|
+
# that contain the variant details.
|
|
122
|
+
def variants(name = nil)
|
|
123
|
+
name ? @variants.find { |v| v[:name] == name } : @variants
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# This method returns only the :files parameter, of the {#variants} return.
|
|
127
|
+
# @param [String] variant_name (nil) Limit the return to this variant, if set
|
|
128
|
+
# @return [Array<String>] An array of grid paths
|
|
129
|
+
def variant_files(variant_name)
|
|
130
|
+
variants(variant_name)[:files]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The plot title, of the given variant
|
|
134
|
+
# @param [String] variant_name The :name of the variant you're looking to title
|
|
135
|
+
# @return [String] The title of the plot
|
|
136
|
+
def title(variant_name)
|
|
137
|
+
@title % variants(variant_name)[:pairs]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Generate an output file path, for the given variant. Typically this is a .csv grid, in the build/grids
|
|
141
|
+
# subdirectory of your project folder.
|
|
142
|
+
# @param [String] name The :name of the variant you're looking for
|
|
143
|
+
# @param [String] ext The file extension you wish to append, to the return
|
|
144
|
+
# @return [String] The path to the output file, of the plot
|
|
145
|
+
def output_file(name, ext)
|
|
146
|
+
RVGP.app.config.build_path format('plots/%<name>s.%<ext>s', name: name, ext: ext)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Generate and return, a plot grid, for the given variant.
|
|
150
|
+
# @param [String] variant_name The :name of the variant you're looking for
|
|
151
|
+
# @return [Array<Array<Object>>] The grid, as an array of arrays.
|
|
152
|
+
def grid(variant_name)
|
|
153
|
+
@grid ||= {}
|
|
154
|
+
@grid[variant_name] ||= begin
|
|
155
|
+
gopts = {}
|
|
156
|
+
rvopts = {
|
|
157
|
+
store_cell: if grid_hacks.key?(:store_cell)
|
|
158
|
+
->(cell) { grid_hacks[:store_cell].call cell: cell }
|
|
159
|
+
else
|
|
160
|
+
->(cell) { cell ? cell.to_f : nil }
|
|
161
|
+
end
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Grid Reader Options:
|
|
165
|
+
rvopts[:keystone] = grid_hacks[:keystone] if grid_hacks.key? :keystone
|
|
166
|
+
|
|
167
|
+
if grid_hacks.key? :select_rows
|
|
168
|
+
rvopts[:select_rows] = ->(name, data) { grid_hacks[:select_rows].call name: name, data: data }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# to_grid Options
|
|
172
|
+
gopts[:truncate_rows] = grid_hacks[:truncate_rows].to_i if grid_hacks.key? :truncate_rows
|
|
173
|
+
gopts[:truncate_columns] = grid_hacks[:truncate_columns].to_i if grid_hacks.key? :truncate_columns
|
|
174
|
+
gopts[:switch_rows_columns] = grid_hacks[:switch_rows_columns] if grid_hacks.key? :switch_rows_columns
|
|
175
|
+
gopts[:sort_rows_by] = ->(row) { grid_hacks[:sort_rows_by].call row: row } if grid_hacks.key? :sort_rows_by
|
|
176
|
+
|
|
177
|
+
if grid_hacks.key? :sort_columns_by
|
|
178
|
+
gopts[:sort_cols_by] = ->(column) { grid_hacks[:sort_columns_by].call column: column }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
RVGP::Utilities::GridQuery.new(variant_files(variant_name), rvopts).to_grid(gopts)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Return the column titles, on the plot for a given variant
|
|
186
|
+
# @param [String] variant_name The :name of the variant you're looking for
|
|
187
|
+
# @return [Array<String>] An array of strings, representing the column titles
|
|
188
|
+
def column_titles(variant_name)
|
|
189
|
+
grid(variant_name)[0]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Return the portion of the grid, containing series labels, and their data.
|
|
193
|
+
# @param [String] variant_name The :name of the variant you're looking for
|
|
194
|
+
# @return [Array<Array<Object>>] The portion of the grid, that contains series data
|
|
195
|
+
def series(variant_name)
|
|
196
|
+
grid(variant_name)[1..]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Return the google plot options, from the yaml of this plot.
|
|
200
|
+
# @return [Hash] The contents of the google: section of this plot's yml
|
|
201
|
+
def google_options
|
|
202
|
+
@google_options = yaml[:google] if yaml.key? :google
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Return the gnuplot object, for a given variant
|
|
206
|
+
# @param [String] name The :name of the variant you're looking for
|
|
207
|
+
# @return [RVGP::Plot::Gnuplot::Plot] The gnuplot
|
|
208
|
+
def gnuplot(name)
|
|
209
|
+
@gnuplots ||= {}
|
|
210
|
+
@gnuplots[name] ||= RVGP::Plot::Gnuplot::Plot.new title(name), grid(name), gnuplot_options
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Return the rendered gnuplot code, for a given variant
|
|
214
|
+
# @param [String] name The :name of the variant you're looking for
|
|
215
|
+
# @return [String] The gnuplot code that represents this variant
|
|
216
|
+
def script(name)
|
|
217
|
+
gnuplot(name).script
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Execute the rendered gnuplot code, for a given variant. Typically, this opens a gnuplot
|
|
221
|
+
# window.
|
|
222
|
+
# @param [String] name The :name of the variant you're looking for
|
|
223
|
+
# @return [void]
|
|
224
|
+
def show(name)
|
|
225
|
+
gnuplot(name).execute!
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Write the gnuplot code, for a given variant, to the :output_file
|
|
229
|
+
# @param [String] name The :name of the variant you're looking for
|
|
230
|
+
# @return [void]
|
|
231
|
+
def write!(name)
|
|
232
|
+
File.write output_file(name, 'gpi'), gnuplot(name).script
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# This returns what plot variants are possible, given a glob, when matched against the
|
|
236
|
+
# provided file names.
|
|
237
|
+
# If pair_values contains key: value combinations, then, any of the returned
|
|
238
|
+
# variants will be sorted under the key:value provided . (Its really just meant
|
|
239
|
+
# for year: 'all', atm..)
|
|
240
|
+
# @param [String] glob A string that matches 'variables' in the form of \\{variablename} specifiers
|
|
241
|
+
# @param [Array<String>] corpus An array of file paths. The paths are matched against the glob, and
|
|
242
|
+
# separated based on the variables found, in their names.
|
|
243
|
+
# @return [Array<Hash<Symbol,Object>>] An array of Hashes, containing :name, :pairs, and :files components
|
|
244
|
+
def self.glob_variants(glob, corpus, pair_values = {})
|
|
245
|
+
variant_names = glob.scan(/%\{([^ }]+)/).flatten.map(&:to_sym)
|
|
246
|
+
|
|
247
|
+
glob_vars = variant_names.to_h { |key| [key, '(.+)'] }
|
|
248
|
+
variant_matcher = Regexp.new format(glob, glob_vars)
|
|
249
|
+
|
|
250
|
+
corpus.each_with_object([]) do |file, ret|
|
|
251
|
+
matches = variant_matcher.match File.basename(file)
|
|
252
|
+
|
|
253
|
+
if matches
|
|
254
|
+
pairs = variant_names.map.with_index do |key, i|
|
|
255
|
+
[key, pair_values.key?(key.to_sym) ? pair_values[key] : matches[i + 1]]
|
|
256
|
+
end.to_h
|
|
257
|
+
|
|
258
|
+
pair_i = ret.find_index { |variant| variant[:pairs] == pairs }
|
|
259
|
+
if pair_i
|
|
260
|
+
ret[pair_i][:files] << file
|
|
261
|
+
else
|
|
262
|
+
ret << { name: File.basename(glob % pairs, '.*'),
|
|
263
|
+
pairs: pairs,
|
|
264
|
+
files: [file] }
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
ret
|
|
269
|
+
end.compact
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Return all the plot objects, initialized from the yaml files in the plot_directory_path
|
|
273
|
+
# @param [String] plot_directory_path A path to search, for (plot) yml files
|
|
274
|
+
# @return [Array<RVGP::Plot>] An array of the plots, available in the provided directory
|
|
275
|
+
def self.all(plot_directory_path)
|
|
276
|
+
Dir.glob(format('%s/*.yml', plot_directory_path)).map { |path| new path }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def grid_hacks
|
|
282
|
+
@grid_hacks = yaml.key?(:grid_hacks) ? yaml[:grid_hacks] : {}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def gnuplot_options
|
|
286
|
+
@gnuplot_options ||= begin
|
|
287
|
+
gnuplot_options = yaml[:gnuplot] || {}
|
|
288
|
+
template = RVGP::Utilities::Yaml.new(format('%s/default.yml', GNUPLOT_RESOURCES_PATH))
|
|
289
|
+
gnuplot_options.merge(template: template)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
require_relative '../journal/pricer'
|
|
7
|
+
require_relative '../pta'
|
|
8
|
+
|
|
9
|
+
module RVGP
|
|
10
|
+
class Pta
|
|
11
|
+
# A plain text accounting adapter implementation, for the 'ledger' pta command.
|
|
12
|
+
# This class conforms the ledger query, and output, interfaces in a ruby-like
|
|
13
|
+
# syntax, and with structured ruby objects as outputs.
|
|
14
|
+
#
|
|
15
|
+
# For a more detailed example of these queries in action, take a look at the
|
|
16
|
+
# {https://github.com/brighton36/rra/blob/main/test/test_pta_adapter.rb test/test_pta_adapter.rb}
|
|
17
|
+
class HLedger < RVGP::Pta
|
|
18
|
+
# @!visibility private
|
|
19
|
+
BIN_PATH = '/usr/bin/hledger'
|
|
20
|
+
|
|
21
|
+
# This module contains intermediary parsing objects, used to represent the output of hledger in
|
|
22
|
+
# a structured and hierarchial format.
|
|
23
|
+
module Output
|
|
24
|
+
# This is a base class from which RVGP::Pta::HLedger's outputs inherit. This class mostly just provides
|
|
25
|
+
# helpers for dealing with the json output that hledger produces.
|
|
26
|
+
# @attr_reader [Json] json a parsed representation of the output from hledger
|
|
27
|
+
# @attr_reader [RVGP::Journal::Pricer] pricer A price exchanger, to use for any currency exchange lookups
|
|
28
|
+
class JsonBase
|
|
29
|
+
attr_reader :json, :pricer
|
|
30
|
+
|
|
31
|
+
# Declare the class, and initialize with the relevant options
|
|
32
|
+
# @param [String] json The json that was produced by hledger, to construct this object
|
|
33
|
+
# @param [Hash] options Additional options
|
|
34
|
+
# @option options [RVGP::Journal::Pricer] :pricer see {RVGP::Pta::Ledger::Output::XmlBase#pricer}
|
|
35
|
+
def initialize(json, options)
|
|
36
|
+
@pricer = options[:pricer] || RVGP::Journal::Pricer.new
|
|
37
|
+
@json = JSON.parse json, symbolize_names: true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def commodity_from_json(json)
|
|
43
|
+
symbol = json[:acommodity]
|
|
44
|
+
raise RVGP::Pta::AssertionError unless json.key? :aquantity
|
|
45
|
+
|
|
46
|
+
currency = RVGP::Journal::Currency.from_code_or_symbol symbol
|
|
47
|
+
# TODO: It seems like HLedger defaults to 10 digits. Probably
|
|
48
|
+
# we should shrink these numbers down to the currency specifier...
|
|
49
|
+
RVGP::Journal::Commodity.new symbol,
|
|
50
|
+
currency ? currency.alphabetic_code : symbol,
|
|
51
|
+
json[:aquantity][:decimalMantissa],
|
|
52
|
+
json[:aquantity][:decimalPlaces]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# An json parser, to structure the output of balance queries to hledger. This object exists, as
|
|
57
|
+
# a return value, from the {RVGP::Pta::HLedger#balance} method
|
|
58
|
+
# @attr_reader [RVGP::Pta::BalanceAccount] accounts The accounts, and their components, that were
|
|
59
|
+
# returned by hledger.
|
|
60
|
+
# @attr_reader [Array<RVGP::Journal::Commodity>] summary_amounts The sum amounts, at the end of the account
|
|
61
|
+
# output.
|
|
62
|
+
class Balance < JsonBase
|
|
63
|
+
attr_reader :accounts, :summary_amounts
|
|
64
|
+
|
|
65
|
+
# Declare the registry, and initialize with the relevant options
|
|
66
|
+
# @param [String] json see {RVGP::Pta::HLedger::Output::JsonBase#initialize}
|
|
67
|
+
# @param [Hash] options see {RVGP::Pta::HLedger::Output::JsonBase#initialize}
|
|
68
|
+
def initialize(json, options = {})
|
|
69
|
+
super json, options
|
|
70
|
+
|
|
71
|
+
raise RVGP::Pta::AssertionError unless @json.length == 2
|
|
72
|
+
|
|
73
|
+
@accounts = @json[0].collect do |json_account|
|
|
74
|
+
# I'm not sure why there are two identical entries here, for fullname
|
|
75
|
+
raise RVGP::Pta::AssertionError unless json_account[0] == json_account[1]
|
|
76
|
+
|
|
77
|
+
RVGP::Pta::BalanceAccount.new(json_account[0],
|
|
78
|
+
json_account[3].collect { |l| commodity_from_json l })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@summary_amounts = @json[1].collect { |json_amount| commodity_from_json json_amount }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# An json parser, to structure the output of register queries to ledger. This object exists, as
|
|
86
|
+
# a return value, from the {RVGP::Pta::HLedger#register} method
|
|
87
|
+
# @attr_reader [RVGP::Pta::RegisterTransaction] transactions The transactions, and their components, that were
|
|
88
|
+
# returned by ledger.
|
|
89
|
+
class Register < JsonBase
|
|
90
|
+
attr_reader :transactions
|
|
91
|
+
|
|
92
|
+
# Declare the registry, and initialize with the relevant options
|
|
93
|
+
# @param [String] json see {RVGP::Pta::HLedger::Output::JsonBase#initialize}
|
|
94
|
+
# @param [Hash] options see {RVGP::Pta::HLedger::Output::JsonBase#initialize}
|
|
95
|
+
def initialize(json, options = {})
|
|
96
|
+
super json, options
|
|
97
|
+
|
|
98
|
+
@transactions = @json.each_with_object([]) do |row, sum|
|
|
99
|
+
row[0] ? (sum << [row]) : (sum.last << row)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@transactions.map! do |postings|
|
|
103
|
+
date = Date.strptime postings[0][0], '%Y-%m-%d'
|
|
104
|
+
|
|
105
|
+
RVGP::Pta::RegisterTransaction.new(
|
|
106
|
+
date,
|
|
107
|
+
postings[0][2], # Payee
|
|
108
|
+
(postings.map do |posting|
|
|
109
|
+
amounts, totals = [posting[3][:pamount], posting[4]].map do |pamounts|
|
|
110
|
+
pamounts.map { |pamount| commodity_from_json pamount }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
RVGP::Pta::RegisterPosting.new(
|
|
114
|
+
posting[3][:paccount],
|
|
115
|
+
amounts,
|
|
116
|
+
totals,
|
|
117
|
+
posting[3][:ptags].to_h do |pair|
|
|
118
|
+
[pair.first, pair.last.empty? ? true : pair.last]
|
|
119
|
+
end,
|
|
120
|
+
pricer: pricer,
|
|
121
|
+
# This sets our date to the end -of the month, if this is a
|
|
122
|
+
# monthly query
|
|
123
|
+
price_date: options[:monthly] ? Date.new(date.year, date.month, -1) : date
|
|
124
|
+
)
|
|
125
|
+
end)
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Return the tags that were found, given the specified journal path, and filters.
|
|
133
|
+
#
|
|
134
|
+
# The behavior between hledger and ledger are rather different here. Ledger has a slightly different
|
|
135
|
+
# featureset than HLedger, regarding tags. As such, while the return format is the same between implementations.
|
|
136
|
+
# The results for a given query won't be identical between pta implementations. Mostly, these results differ
|
|
137
|
+
# when a \\{values: true} option is supplied. In that case, ledger will return tags in a series of keys and
|
|
138
|
+
# values, separated by a colon, one per line. hledger, in that case, will only return the tag values themselves,
|
|
139
|
+
# without denotating their key.
|
|
140
|
+
#
|
|
141
|
+
# This method will simply parse the output of hledger, and return that.
|
|
142
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
143
|
+
# details
|
|
144
|
+
# @return [Array<String>] An array of the lines returned by hledger, split into strings. In most cases, this
|
|
145
|
+
# could also be described as simply 'an array of the filtered tags'.
|
|
146
|
+
def tags(*args)
|
|
147
|
+
args, opts = args_and_opts(*args)
|
|
148
|
+
command('tags', *args, opts).split("\n")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Return the files that were encountered, when parsing the provided arguments.
|
|
152
|
+
# The output of this method should be identical, regardless of the Pta Adapter that resolves the request.
|
|
153
|
+
#
|
|
154
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
155
|
+
# details
|
|
156
|
+
# @return [Array<String>] An array of paths that were referenced when fetching data in provided arguments.
|
|
157
|
+
def files(*args)
|
|
158
|
+
args, opts = args_and_opts(*args)
|
|
159
|
+
# TODO: This should get its own error class...
|
|
160
|
+
raise StandardError, "Unexpected argument(s) : #{args.inspect}" unless args.empty?
|
|
161
|
+
|
|
162
|
+
command('files', opts).split("\n")
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Returns the newest transaction, retured in set of transactions filtered with the provided arguments.
|
|
166
|
+
# This method is mostly a wrapper around {#register}, which a return of the .last element in its set.
|
|
167
|
+
# The only reason this method here is to ensure parity with the {RVGP::Pta::Ledger} class, which, exists
|
|
168
|
+
# because an accelerated query is offered by that pta implementation. This method may produce
|
|
169
|
+
# counterintutive results, if you override the sort: option.
|
|
170
|
+
#
|
|
171
|
+
# NOTE: For almost any case you think you want to use this method, {#newest_transaction_date} is probably
|
|
172
|
+
# what you want, as that function has an accelerated implementation provided by hledger.
|
|
173
|
+
#
|
|
174
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
175
|
+
# details.
|
|
176
|
+
# @return [RVGP::Pta::RegisterTransaction] The newest transaction in the set
|
|
177
|
+
def newest_transaction(*args)
|
|
178
|
+
register(*args)&.transactions&.last
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Returns the oldest transaction, retured in set of transactions filtered with the provided arguments.
|
|
182
|
+
# This method is mostly a wrapper around {RVGP::Pta::HLedger#register}, which a return of the .last element in its
|
|
183
|
+
# set. The only reason this method here is to ensure parity with the {RVGP::Pta::Ledger} class, which, exists
|
|
184
|
+
# because an accelerated query is offered by that pta implementation. This method may produce
|
|
185
|
+
# counterintutive results, if you override the sort: option.
|
|
186
|
+
#
|
|
187
|
+
# NOTE: There is almost certainly, no a good reason to be using this method. Perhaps in the future,
|
|
188
|
+
# hledger will offer an equivalent to ledger's --head and --tail options, at which time this method would
|
|
189
|
+
# make sense.
|
|
190
|
+
#
|
|
191
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
192
|
+
# details.
|
|
193
|
+
# @return [RVGP::Pta::RegisterTransaction] The oldest transaction in the set
|
|
194
|
+
def oldest_transaction(*args)
|
|
195
|
+
register(*args)&.transactions&.first
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Returns the value of the 'Last transaction' key, of the #{RVGP::Pta#stats} method. This method is a fast query
|
|
199
|
+
# to resolve.
|
|
200
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
201
|
+
# details.
|
|
202
|
+
# @return [Date] The date of the newest transaction found in your files.
|
|
203
|
+
def newest_transaction_date(*args)
|
|
204
|
+
Date.strptime stats(*args)['Last transaction'], '%Y-%m-%d'
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Run the 'hledger balance' command, and return it's output.
|
|
208
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
209
|
+
# details.
|
|
210
|
+
# @return [RVGP::Pta::HLedger::Output::Balance] A parsed, hierarchial, representation of the output
|
|
211
|
+
def balance(*args)
|
|
212
|
+
args, opts = args_and_opts(*args)
|
|
213
|
+
RVGP::Pta::HLedger::Output::Balance.new command('balance', *args, { 'output-format': 'json' }.merge(opts))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Run the 'hledger register' command, and return it's output.
|
|
217
|
+
#
|
|
218
|
+
# This method also supports the following options, for additional handling:
|
|
219
|
+
# - **:pricer** (RVGP::Journal::Pricer) - If provided, this option will use the specified pricer object when
|
|
220
|
+
# calculating exchange rates.
|
|
221
|
+
#
|
|
222
|
+
# @param [Array<Object>] args Arguments and options, passed to the pta command. See {RVGP::Pta#args_and_opts} for
|
|
223
|
+
# details.
|
|
224
|
+
# @return [RVGP::Pta::HLedger::Output::Register] A parsed, hierarchial, representation of the output
|
|
225
|
+
def register(*args)
|
|
226
|
+
args, opts = args_and_opts(*args)
|
|
227
|
+
|
|
228
|
+
pricer = opts.delete :pricer
|
|
229
|
+
|
|
230
|
+
# TODO: Provide and Test translate_meta_accounts here
|
|
231
|
+
RVGP::Pta::HLedger::Output::Register.new command('register', *args, { 'output-format': 'json' }.merge(opts)),
|
|
232
|
+
monthly: (opts[:monthly] == true),
|
|
233
|
+
pricer: pricer
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|