rvgp 0.3.2

Sign up to get free protection for your applications and to get access to all the features.
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