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.
Files changed (97) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +8 -0
  3. data/.rubocop.yml +23 -0
  4. data/LICENSE +504 -0
  5. data/README.md +223 -0
  6. data/Rakefile +32 -0
  7. data/bin/rvgp +8 -0
  8. data/lib/rvgp/application/config.rb +159 -0
  9. data/lib/rvgp/application/descendant_registry.rb +122 -0
  10. data/lib/rvgp/application/status_output.rb +139 -0
  11. data/lib/rvgp/application.rb +170 -0
  12. data/lib/rvgp/base/command.rb +457 -0
  13. data/lib/rvgp/base/grid.rb +531 -0
  14. data/lib/rvgp/base/reader.rb +29 -0
  15. data/lib/rvgp/base/reconciler.rb +434 -0
  16. data/lib/rvgp/base/validation.rb +261 -0
  17. data/lib/rvgp/commands/cashflow.rb +160 -0
  18. data/lib/rvgp/commands/grid.rb +70 -0
  19. data/lib/rvgp/commands/ireconcile.rb +95 -0
  20. data/lib/rvgp/commands/new_project.rb +296 -0
  21. data/lib/rvgp/commands/plot.rb +41 -0
  22. data/lib/rvgp/commands/publish_gsheets.rb +83 -0
  23. data/lib/rvgp/commands/reconcile.rb +58 -0
  24. data/lib/rvgp/commands/rotate_year.rb +202 -0
  25. data/lib/rvgp/commands/validate_journal.rb +59 -0
  26. data/lib/rvgp/commands/validate_system.rb +44 -0
  27. data/lib/rvgp/commands.rb +160 -0
  28. data/lib/rvgp/dashboard.rb +252 -0
  29. data/lib/rvgp/fakers/fake_feed.rb +245 -0
  30. data/lib/rvgp/fakers/fake_journal.rb +57 -0
  31. data/lib/rvgp/fakers/fake_reconciler.rb +88 -0
  32. data/lib/rvgp/fakers/faker_helpers.rb +25 -0
  33. data/lib/rvgp/gem.rb +80 -0
  34. data/lib/rvgp/journal/commodity.rb +453 -0
  35. data/lib/rvgp/journal/complex_commodity.rb +214 -0
  36. data/lib/rvgp/journal/currency.rb +101 -0
  37. data/lib/rvgp/journal/journal.rb +141 -0
  38. data/lib/rvgp/journal/posting.rb +156 -0
  39. data/lib/rvgp/journal/pricer.rb +267 -0
  40. data/lib/rvgp/journal.rb +24 -0
  41. data/lib/rvgp/plot/gnuplot.rb +478 -0
  42. data/lib/rvgp/plot/google-drive/output_csv.rb +44 -0
  43. data/lib/rvgp/plot/google-drive/output_google_sheets.rb +434 -0
  44. data/lib/rvgp/plot/google-drive/sheet.rb +67 -0
  45. data/lib/rvgp/plot.rb +293 -0
  46. data/lib/rvgp/pta/hledger.rb +237 -0
  47. data/lib/rvgp/pta/ledger.rb +308 -0
  48. data/lib/rvgp/pta.rb +311 -0
  49. data/lib/rvgp/reconcilers/csv_reconciler.rb +424 -0
  50. data/lib/rvgp/reconcilers/journal_reconciler.rb +41 -0
  51. data/lib/rvgp/reconcilers/shorthand/finance_gem_hacks.rb +48 -0
  52. data/lib/rvgp/reconcilers/shorthand/international_atm.rb +152 -0
  53. data/lib/rvgp/reconcilers/shorthand/investment.rb +144 -0
  54. data/lib/rvgp/reconcilers/shorthand/mortgage.rb +195 -0
  55. data/lib/rvgp/utilities/grid_query.rb +190 -0
  56. data/lib/rvgp/utilities/yaml.rb +131 -0
  57. data/lib/rvgp/utilities.rb +44 -0
  58. data/lib/rvgp/validations/balance_validation.rb +68 -0
  59. data/lib/rvgp/validations/duplicate_tags_validation.rb +48 -0
  60. data/lib/rvgp/validations/uncategorized_validation.rb +15 -0
  61. data/lib/rvgp.rb +66 -0
  62. data/resources/README.MD/2022-cashflow-google.png +0 -0
  63. data/resources/README.MD/2022-cashflow.png +0 -0
  64. data/resources/README.MD/all-wealth-growth-google.png +0 -0
  65. data/resources/README.MD/all-wealth-growth.png +0 -0
  66. data/resources/gnuplot/default.yml +80 -0
  67. data/resources/i18n/en.yml +192 -0
  68. data/resources/iso-4217-currencies.json +171 -0
  69. data/resources/skel/Rakefile +5 -0
  70. data/resources/skel/app/grids/cashflow_grid.rb +27 -0
  71. data/resources/skel/app/grids/monthly_income_and_expenses_grid.rb +25 -0
  72. data/resources/skel/app/grids/wealth_growth_grid.rb +35 -0
  73. data/resources/skel/app/plots/cashflow.yml +33 -0
  74. data/resources/skel/app/plots/monthly-income-and-expenses.yml +17 -0
  75. data/resources/skel/app/plots/wealth-growth.yml +20 -0
  76. data/resources/skel/config/csv-format-acme-checking.yml +9 -0
  77. data/resources/skel/config/google-secrets.yml +5 -0
  78. data/resources/skel/config/rvgp.yml +0 -0
  79. data/resources/skel/journals/prices.db +0 -0
  80. data/rvgp.gemspec +6 -0
  81. data/test/assets/ledger_total_monthly_liabilities_with_empty.xml +383 -0
  82. data/test/assets/ledger_total_monthly_liabilities_with_empty2.xml +428 -0
  83. data/test/test_command_base.rb +61 -0
  84. data/test/test_commodity.rb +270 -0
  85. data/test/test_csv_reconciler.rb +60 -0
  86. data/test/test_currency.rb +24 -0
  87. data/test/test_fake_feed.rb +228 -0
  88. data/test/test_fake_journal.rb +98 -0
  89. data/test/test_fake_reconciler.rb +60 -0
  90. data/test/test_journal_parse.rb +545 -0
  91. data/test/test_ledger.rb +102 -0
  92. data/test/test_plot.rb +133 -0
  93. data/test/test_posting.rb +50 -0
  94. data/test/test_pricer.rb +139 -0
  95. data/test/test_pta_adapter.rb +575 -0
  96. data/test/test_utilities.rb +45 -0
  97. metadata +268 -0
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n'
4
+ require 'pastel'
5
+ require 'tty-screen'
6
+
7
+ module RVGP
8
+ class Application
9
+ # These methods output 'pretty' indications of the build process, and is used
10
+ # by both the rvgp standalone bin, as well as the rake processes themselves.
11
+ # This class manages colors, icons, indentation and thread syncronization
12
+ # concerns, relating to status display.
13
+ # NOTE: This class doesn't know if it wants to be a logger or something else... Let's
14
+ # see where it ends up
15
+ #
16
+ # @attr_reader [Integer] tty_cols The width of the terminal, in characters - This number is calculated at the time
17
+ # of Object instantiation.
18
+ class StatusOutputRake
19
+ attr_reader :tty_cols
20
+
21
+ # Create a new STDOUT status output logger
22
+ # @param [Hash] opts what options to configure this registry with
23
+ # @option opts [Pastel] :pastel A pastel object to use, for formatting output
24
+ def initialize(opts = {})
25
+ @semaphore = Mutex.new
26
+ @pastel = opts[:pastel] || Pastel.new
27
+
28
+ @last_header_outputted = nil
29
+
30
+ begin
31
+ @tty_cols = TTY::Screen.width
32
+ rescue Errno::ENOENT
33
+ @tty_cols = 0
34
+ end
35
+
36
+ # NOTE: This is the smallest width I bothered testing on. I don't think
37
+ # we really care to support this code path in general. If you're this
38
+ # narrow, umm, fix that.
39
+ @tty_cols = 40 if @tty_cols < 40
40
+
41
+ @complete = I18n.t 'status.indicators.complete'
42
+ @indent = I18n.t 'status.indicators.indent'
43
+ @fill = I18n.t 'status.indicators.fill'
44
+ @attention1 = I18n.t 'status.indicators.attention1'
45
+ @attention2 = I18n.t 'status.indicators.attention2'
46
+ @truncated = I18n.t 'status.indicators.truncated'
47
+ end
48
+
49
+ # This is the only public interface, at this time, for use in outputting status.
50
+ # This is the only method that RVGP needs implemented, on a status output object, should
51
+ # you choose to write your own.
52
+ # @param [String] cmd The command that's emmitting this status message
53
+ # @param [String] desc The I18n key, appended to 'status.commands.', that contains the message you wish to output
54
+ # @yield [void] A block that contains the execution of this message, and which will return an execution status.
55
+ # @yieldreturn [Hash<Symbol, Array>] A hash, with the keys :errors, and :warnings, each of which contains a list
56
+ # of errors and warnings that occurred, during the execution of this block.
57
+ # These will be stylized and output to the user.
58
+ # @return [void]
59
+ def info(cmd, desc, &block)
60
+ icon, header, prefix = *%w[icon header prefix].map do |attr|
61
+ I18n.t format('status.commands.%<cmd>s.%<attr>s', cmd: cmd.to_s, attr: attr)
62
+ end
63
+
64
+ @semaphore.synchronize do
65
+ unless @last_header_outputted == cmd
66
+ puts ["\n", icon, ' ', @pastel.bold(header)].join
67
+ @last_header_outputted = cmd
68
+ end
69
+ end
70
+
71
+ ret = block.call || {}
72
+
73
+ has_warn = ret.key?(:warnings) && !ret[:warnings].empty?
74
+ has_fail = ret.key?(:errors) && !ret[:errors].empty?
75
+
76
+ status_length = (if has_warn && has_fail
77
+ I18n.t 'status.indicators.complete_and', left: @complete, right: @complete
78
+ else
79
+ @complete
80
+ end).length
81
+
82
+ status = if has_warn && has_fail
83
+ I18n.t 'status.indicators.complete_and',
84
+ left: @pastel.yellow(@complete),
85
+ right: @pastel.red(@complete)
86
+ elsif has_warn
87
+ @pastel.yellow(@complete)
88
+ elsif has_fail
89
+ @pastel.red(@complete)
90
+ else
91
+ @pastel.green(@complete)
92
+ end
93
+
94
+ fixed_element_width = ([@indent, prefix, ' ', @indent, ' ', ' '].join.length + status_length)
95
+
96
+ available_width = tty_cols - fixed_element_width
97
+
98
+ # If the description is gigantic, we need to constrain it. That's our
99
+ # variable-length column, so to speak:
100
+ fill = if available_width <= desc.length
101
+ # The plus one is due to the compact'd nil below, which, removes a space
102
+ # character, that would have otherwise been placed next to the @fill
103
+ desc = desc[0...available_width - @truncated.length - 1] + @truncated
104
+ ' '
105
+ elsif (available_width - desc.length) > 1
106
+ [' ', @fill * (available_width - desc.length - 2), ' '].join
107
+ elsif (available_width - desc.length) == 1
108
+ ' '
109
+ else # This should only be zero
110
+ ''
111
+ end
112
+
113
+ @semaphore.synchronize do
114
+ puts [
115
+ [@indent, @pastel.bold(prefix), ' ', @pastel.blue(desc), fill, status].join,
116
+ has_warn ? status_tree(:yellow, ret[:warnings]) : nil,
117
+ has_fail ? status_tree(:red, ret[:errors]) : nil
118
+ ].compact.flatten.join("\n")
119
+ end
120
+
121
+ ret
122
+ end
123
+
124
+ private
125
+
126
+ def status_tree(color, results)
127
+ results.map do |result|
128
+ result.each_with_index.map do |messages, depth|
129
+ (messages.is_a?(String) ? [messages] : messages).map do |msg|
130
+ [@indent * 2, @indent * depth,
131
+ depth.positive? ? @pastel.send(color, @attention2) : @pastel.send(color, @attention1),
132
+ depth.positive? ? msg : @pastel.send(color, msg)].join
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'application/status_output'
4
+ require_relative 'application/config'
5
+
6
+ module RVGP
7
+ # The main application class, by which all projects are defined. This class
8
+ # contains the methods and properties that are intrinsic to the early stages of
9
+ # project initialization, and which provides the functionality used by
10
+ # submodules initialized after initialization. In addition, this class implements
11
+ # the main() entry point used by Rakefiles, and in turn, instigates the
12
+ # equivalent entry points in various modules thereafter.
13
+ #
14
+ # @attr_reader [String] project_path The directory path, from which this application was initialized.
15
+ # @attr_reader [RVGP::Application::StatusOutputRake] logger The application logger. This is provided so that callers
16
+ # can output to the console. (Or wherever the output device is logging)
17
+ # @attr_reader [RVGP::Journal::Pricer] pricer This attribute contains the pricer that's used by the application. Price
18
+ # data is automatically loaded from config.prices_path (typically
19
+ # 'journals/prices.db')
20
+ # @attr_reader [RVGP::Application::Config] config The application configuration, most of which is parsed from the
21
+ # config.yaml
22
+ class Application
23
+ # This error is thrown when the project_path provided to {Application#initialize} doesn't exist, and/or is
24
+ # otherwise invalid.
25
+ class InvalidProjectDir < StandardError; end
26
+
27
+ attr_reader :project_path, :logger, :pricer, :config
28
+
29
+ # Creates an instance of Application, given the files and structure of the provided project path.
30
+ # @param project_path [String] The path, to an RVGP project directory.
31
+ def initialize(project_path)
32
+ raise InvalidProjectDir unless [project_path, format('%s/app', project_path)].all? { |f| Dir.exist? f }
33
+
34
+ @project_path = project_path
35
+ @config = RVGP::Application::Config.new project_path
36
+ @logger = RVGP::Application::StatusOutputRake.new pastel: RVGP.pastel
37
+
38
+ if File.exist? config.prices_path
39
+ @pricer = RVGP::Journal::Pricer.new(
40
+ File.read(config.prices_path),
41
+ # See the documentation in RVGP::Journal::Pricer#initialize, to better understand what's happening here.
42
+ # And, Note that this functionality is only supported when ledger is the pta adapter.
43
+ before_price_add: lambda { |time, from_alpha, to|
44
+ puts [
45
+ RVGP.pastel.yellow(I18n.t('error.warning')),
46
+ I18n.t('error.missing_entry_in_prices_db', time: time, from: from_alpha, to: to)
47
+ ].join ' '
48
+ }
49
+ )
50
+ end
51
+
52
+ # Include all the project files:
53
+ require_commands!
54
+ require_validations!
55
+ require_grids!
56
+ end
57
+
58
+ # @return [Array] An array, containing all the reconciler objects, defined in the project
59
+ def reconcilers
60
+ @reconcilers ||= RVGP::Base::Reconciler.all project_path
61
+ end
62
+
63
+ # This method will insert all the project tasks, into a Rake object.
64
+ # Typically, 'self' is that object, when calling from a Rakefile. (aka 'main')
65
+ # @param rake_main [Object] The Rake object to attach RVGP to.
66
+ # @return [void]
67
+ def initialize_rake!(rake_main)
68
+ require 'rake/clean'
69
+
70
+ CLEAN.include FileList[RVGP.app.config.build_path('*')]
71
+
72
+ # This removes clobber from the task list:
73
+ Rake::Task['clobber'].clear_comments
74
+
75
+ project_tasks_dir = format('%s/tasks', project_path)
76
+ Rake.add_rakelib project_tasks_dir if File.directory? project_tasks_dir
77
+
78
+ RVGP.commands.each do |command_klass|
79
+ command_klass.initialize_rake rake_main if command_klass.respond_to? :initialize_rake
80
+ end
81
+
82
+ rake_main.instance_eval do
83
+ default_tasks = %i[reconcile validate_journal validate_system]
84
+ multitask reconcile: RVGP.app.reconcilers.map { |tf| "reconcile:#{tf.as_taskname}" }
85
+ multitask validate_journal: RVGP.app.reconcilers.map { |tf| "validate_journal:#{tf.as_taskname}" }
86
+ multitask validate_system: RVGP.system_validations.task_names
87
+
88
+ # There's a chicken-and-an-egg problem that's due:
89
+ # - users (potentially) wanting to see/trigger specific plot and grid targets, in a clean project
90
+ # - A pre-requisite that journals (and grids) exist, in order to determine what grid/plot targets
91
+ # are available.
92
+ # So, what we do here, is do our best to determine what's available in a clean build. And, at the
93
+ # time at which we're ready to start buildings grids/plots - we re-initialize the available tasks
94
+ # based on what was built prior in the running build.
95
+ #
96
+ # Most grids can be determined by examining the reconciler years that exist in the app/ directory.
97
+ # But, in the case that new year starts, and the prior year hasn't been rotated, we'll be adding
98
+ # additional grids here.
99
+ #
100
+ # As for plots... probably we can do a better job of pre-determining those. But, they're pretty
101
+ # inconsequential in the build time, so, unless someone needs this feature for some reason, there
102
+ # are 'no' plots at the time of a full rake build, and the rescan adds them here after the grids
103
+ # are built.
104
+
105
+ isnt_reconciled = RVGP::Commands::Reconcile::Target.all.any? { |t| !t.uptodate? }
106
+ if isnt_reconciled
107
+ desc I18n.t('commands.rescan_grids.target_description')
108
+ task :rescan_grids do |_task, _task_args|
109
+ RVGP::Commands::Grid.initialize_rake rake_main
110
+ multitask grid: RVGP.grids.task_names
111
+ end
112
+ default_tasks << :rescan_grids
113
+ end
114
+
115
+ default_tasks << :grid
116
+ multitask grid: RVGP.grids.task_names
117
+
118
+ if isnt_reconciled || RVGP::Commands::Grid::Target.all.any? { |t| !t.uptodate? }
119
+ # This re-registers the grid tasks, into the rake
120
+ desc I18n.t('commands.rescan_plots.target_description')
121
+ task :rescan_plots do |_task, _task_args|
122
+ RVGP::Commands::Plot.initialize_rake rake_main
123
+ multitask plot: RVGP::Commands::Plot::Target.all.map { |t| "plot:#{t.name}" }
124
+ end
125
+ default_tasks << :rescan_plots
126
+ end
127
+
128
+ default_tasks << :plot
129
+ multitask plot: RVGP::Commands::Plot::Target.all.map { |t| "plot:#{t.name}" }
130
+
131
+ task default: default_tasks
132
+ end
133
+ end
134
+
135
+ # This helper method will create the provided subdir inside the project's build/ directory, if that
136
+ # subdir doesn't already exist. In the case that this subdir already exists, the method terminates
137
+ # gracefully, without action.
138
+ # @return [void]
139
+ def ensure_build_dir!(subdir)
140
+ path = RVGP.app.config.build_path subdir
141
+ FileUtils.mkdir_p path unless File.directory? path
142
+ end
143
+
144
+ private
145
+
146
+ def require_commands!
147
+ # Built-in commands:
148
+ RVGP::Commands.require_files!
149
+
150
+ # App commands:
151
+ require_app_files! 'commands'
152
+ end
153
+
154
+ def require_grids!
155
+ require_app_files! 'grids'
156
+ end
157
+
158
+ def require_validations!
159
+ # Built-in validations:
160
+ Dir.glob(RVGP::Gem.root('lib/rvgp/validations/*.rb')).sort.each { |file| require file }
161
+
162
+ # App validations:
163
+ require_app_files! 'validations'
164
+ end
165
+
166
+ def require_app_files!(subdir)
167
+ Dir.glob([project_path, 'app', subdir, '*.rb'].join('/')).sort.each { |file| require file }
168
+ end
169
+ end
170
+ end