appydave-tools 0.84.0 → 0.86.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/CLAUDE.md +1 -1
  4. data/CONTEXT.md +133 -23
  5. data/bin/configuration.rb +20 -0
  6. data/bin/query_apps.rb +74 -0
  7. data/config/examples/locations.example.json +48 -0
  8. data/config/examples/settings.example.json +9 -0
  9. data/config/random-queries.yml +24 -45
  10. data/context.globs.json +24 -0
  11. data/docs/guides/tools/dam/dam-usage.md +261 -471
  12. data/docs/planning/multi-user-support.md +108 -0
  13. data/docs/planning/query-apps-design.md +344 -0
  14. data/docs/planning/query-skills-plan.md +354 -0
  15. data/docs/specs/jump-add-display-fix.md +249 -0
  16. data/exe/query_apps +7 -0
  17. data/lib/appydave/tools/app_context/app_finder.rb +176 -0
  18. data/lib/appydave/tools/app_context/globs_loader.rb +116 -0
  19. data/lib/appydave/tools/app_context/options.rb +28 -0
  20. data/lib/appydave/tools/bank_reconciliation/_doc.md +36 -0
  21. data/lib/appydave/tools/bank_reconciliation/clean/clean_transactions.rb +159 -0
  22. data/lib/appydave/tools/bank_reconciliation/clean/mapper.rb +133 -0
  23. data/lib/appydave/tools/bank_reconciliation/clean/read_transactions.rb +258 -0
  24. data/lib/appydave/tools/bank_reconciliation/models/transaction.rb +158 -0
  25. data/lib/appydave/tools/brain_context/options.rb +19 -2
  26. data/lib/appydave/tools/configuration/example_installer.rb +72 -0
  27. data/lib/appydave/tools/configuration/models/bank_reconciliation_config.rb +152 -0
  28. data/lib/appydave/tools/configuration/models/settings_config.rb +12 -0
  29. data/lib/appydave/tools/jump/formatters/table_formatter.rb +16 -5
  30. data/lib/appydave/tools/version.rb +1 -1
  31. data/lib/appydave/tools/zsh_history/config.rb +2 -2
  32. data/lib/appydave/tools/zsh_history/filter.rb +10 -4
  33. data/lib/appydave/tools.rb +12 -0
  34. data/package.json +1 -1
  35. metadata +36 -2
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Appydave
6
+ module Tools
7
+ module AppContext
8
+ # Queries locations.json to find app files via context.globs.json patterns.
9
+ #
10
+ # Follows the same find/find_meta pattern as BrainQuery and OmiQuery.
11
+ class AppQuery
12
+ def initialize(options, jump_config: nil)
13
+ @options = options
14
+ @jump_config = jump_config
15
+ end
16
+
17
+ # Return absolute file paths matching the query
18
+ def find
19
+ return [] unless @options.query?
20
+
21
+ apps = resolve_apps
22
+ return [] if apps.empty?
23
+
24
+ apps.flat_map { |app| expand_app(app) }.uniq.sort
25
+ end
26
+
27
+ # Return structured metadata about the query results
28
+ def find_meta
29
+ return [] unless @options.query?
30
+
31
+ apps = resolve_apps
32
+ return [] if apps.empty?
33
+
34
+ apps.map { |app| build_meta(app) }
35
+ end
36
+
37
+ # List all available glob names for a specific app
38
+ def list_globs(app_name)
39
+ location = find_location(app_name)
40
+ return [] unless location
41
+
42
+ loader = build_loader(location)
43
+ return [] unless loader.available?
44
+
45
+ loader.available_names
46
+ end
47
+
48
+ # List all apps that have context.globs.json
49
+ def list_apps
50
+ jump_config.locations
51
+ .select { |loc| File.exist?(File.join(expand_path(loc.path), 'context.globs.json')) }
52
+ .map do |loc|
53
+ loader = build_loader(loc)
54
+ {
55
+ 'key' => loc.key,
56
+ 'path' => expand_path(loc.path),
57
+ 'pattern' => loader.pattern,
58
+ 'glob_count' => loader.globs.size
59
+ }
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def jump_config
66
+ @jump_config ||= Jump::Config.new
67
+ end
68
+
69
+ # Resolve app names to Location objects
70
+ def resolve_apps
71
+ if @options.pattern_filter
72
+ resolve_by_pattern(@options.pattern_filter)
73
+ else
74
+ @options.app_names.flat_map { |name| resolve_app(name) }.compact.uniq(&:key)
75
+ end
76
+ end
77
+
78
+ # 4-tier app resolution
79
+ def resolve_app(name)
80
+ name_down = name.downcase
81
+
82
+ # Tier 1: exact key match
83
+ loc = jump_config.find(name_down)
84
+ return [loc] if loc
85
+
86
+ # Tier 2: jump alias match
87
+ loc = jump_config.locations.find { |l| l.jump&.downcase == name_down }
88
+ return [loc] if loc
89
+
90
+ # Tier 3: substring match on key
91
+ matches = jump_config.locations.select { |l| l.key.downcase.include?(name_down) }
92
+ return matches unless matches.empty?
93
+
94
+ # Tier 4: substring match on description
95
+ jump_config.locations.select { |l| l.description&.downcase&.include?(name_down) }
96
+ end
97
+
98
+ def resolve_by_pattern(pattern)
99
+ jump_config.locations.select do |loc|
100
+ loader = build_loader(loc)
101
+ loader.available? && loader.pattern&.downcase == pattern.downcase
102
+ end
103
+ end
104
+
105
+ def expand_app(location)
106
+ loader = build_loader(location)
107
+ return [] unless loader.available?
108
+
109
+ glob_names = @options.glob_names
110
+ return [] if glob_names.empty?
111
+
112
+ loader.expand(glob_names)
113
+ end
114
+
115
+ def build_meta(location)
116
+ loader = build_loader(location)
117
+ glob_names = @options.glob_names
118
+ file_count = loader.available? && glob_names.any? ? loader.expand(glob_names).size : 0
119
+
120
+ resolved_from = glob_names.map { |n| describe_resolution(loader, n) }.join(', ')
121
+
122
+ {
123
+ 'app' => location.key,
124
+ 'path' => expand_path(location.path),
125
+ 'pattern' => loader.pattern,
126
+ 'matched_globs' => resolve_glob_names(loader, glob_names),
127
+ 'resolved_from' => resolved_from,
128
+ 'file_count' => file_count
129
+ }
130
+ end
131
+
132
+ def build_loader(location)
133
+ GlobsLoader.new(expand_path(location.path))
134
+ end
135
+
136
+ def expand_path(path)
137
+ File.expand_path(path)
138
+ end
139
+
140
+ def find_location(app_name)
141
+ results = resolve_app(app_name)
142
+ results&.first
143
+ end
144
+
145
+ # Describe how a glob name was resolved (for meta output)
146
+ def describe_resolution(loader, name)
147
+ name = name.strip.downcase
148
+ return "#{name} (glob)" if loader.globs.key?(name)
149
+ return "#{name} (alias)" if loader.aliases.key?(name)
150
+ return "#{name} (composite)" if loader.composites.key?(name)
151
+
152
+ "#{name} (fuzzy)"
153
+ end
154
+
155
+ # Resolve glob names to their constituent direct glob names
156
+ def resolve_glob_names(loader, names)
157
+ return [] unless loader.available?
158
+
159
+ names.flat_map do |name|
160
+ name = name.strip.downcase
161
+ if loader.globs.key?(name)
162
+ [name]
163
+ elsif loader.aliases.key?(name)
164
+ loader.aliases[name]
165
+ elsif loader.composites.key?(name)
166
+ members = loader.composites[name]
167
+ members == ['*'] ? loader.globs.keys : members
168
+ else # rubocop:disable Lint/DuplicateBranch
169
+ [name]
170
+ end
171
+ end.uniq
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Appydave
6
+ module Tools
7
+ module AppContext
8
+ # Loads and resolves named glob patterns from a context.globs.json file.
9
+ #
10
+ # Resolution is 3-tier:
11
+ # 1. Direct glob name — "services" → globs["services"]
12
+ # 2. Alias match — "backend" → aliases["backend"] → ["services", "routes"]
13
+ # 3. Composite match — "understand" → composites["understand"] → ["context", "docs", ...]
14
+ class GlobsLoader
15
+ attr_reader :project_path, :data
16
+
17
+ def initialize(project_path)
18
+ @project_path = project_path
19
+ @data = load_globs_file
20
+ end
21
+
22
+ def available?
23
+ !data.nil?
24
+ end
25
+
26
+ def globs
27
+ data&.fetch('globs', {}) || {}
28
+ end
29
+
30
+ def aliases
31
+ data&.fetch('aliases', {}) || {}
32
+ end
33
+
34
+ def composites
35
+ data&.fetch('composites', {}) || {}
36
+ end
37
+
38
+ def pattern
39
+ data&.fetch('pattern', nil)
40
+ end
41
+
42
+ def project_name
43
+ data&.fetch('project', nil)
44
+ end
45
+
46
+ # List all available glob names (direct + aliases + composites)
47
+ def available_names
48
+ names = globs.keys.map { |k| { name: k, type: 'glob' } }
49
+ names += aliases.keys.map { |k| { name: k, type: 'alias' } }
50
+ names += composites.keys.map { |k| { name: k, type: 'composite' } }
51
+ names
52
+ end
53
+
54
+ # Resolve a single name through the 3-tier hierarchy.
55
+ # Returns an array of raw glob patterns (strings).
56
+ def resolve(name)
57
+ name = name.strip.downcase
58
+
59
+ # Tier 1: direct glob name
60
+ return globs[name] if globs.key?(name)
61
+
62
+ # Tier 2: alias → list of glob names
63
+ return aliases[name].flat_map { |glob_name| globs[glob_name] || [] } if aliases.key?(name)
64
+
65
+ # Tier 3: composite → list of glob names (or "*" for all)
66
+ if composites.key?(name)
67
+ members = composites[name]
68
+ return globs.values.flatten if members == ['*']
69
+
70
+ return members.flat_map { |glob_name| resolve_single_glob(glob_name) }
71
+ end
72
+
73
+ # Tier 4: substring fallback
74
+ match = find_substring_match(name)
75
+ return resolve(match) if match
76
+
77
+ []
78
+ end
79
+
80
+ # Resolve multiple names, expand globs against the filesystem, return absolute paths.
81
+ def expand(names)
82
+ patterns = names.flat_map { |name| resolve(name) }.uniq
83
+
84
+ patterns.flat_map { |pat| Dir.glob(File.join(project_path, pat)) }
85
+ .select { |f| File.file?(f) }
86
+ .uniq
87
+ .sort
88
+ end
89
+
90
+ private
91
+
92
+ def globs_file_path
93
+ File.join(project_path, 'context.globs.json')
94
+ end
95
+
96
+ def load_globs_file
97
+ path = globs_file_path
98
+ return nil unless File.exist?(path)
99
+
100
+ JSON.parse(File.read(path))
101
+ rescue JSON::ParserError
102
+ nil
103
+ end
104
+
105
+ def resolve_single_glob(glob_name)
106
+ globs[glob_name] || []
107
+ end
108
+
109
+ def find_substring_match(name)
110
+ all_names = globs.keys + aliases.keys + composites.keys
111
+ all_names.find { |n| n.include?(name) }
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module AppContext
6
+ # Options struct for app context query tool
7
+ class Options
8
+ attr_accessor :app_names, :glob_names, :pattern_filter,
9
+ :meta, :list, :list_apps,
10
+ :debug_level
11
+
12
+ def initialize
13
+ @app_names = []
14
+ @glob_names = []
15
+ @pattern_filter = nil
16
+ @meta = false
17
+ @list = false
18
+ @list_apps = false
19
+ @debug_level = 'none'
20
+ end
21
+
22
+ def query?
23
+ app_names.any? || !pattern_filter.nil?
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # Bank reconciliation
2
+
3
+ [ChatGPT conversation](https://chatgpt.com/c/5d382562-95e5-4243-9b74-c3807d363486)
4
+
5
+
6
+ ## Code structure
7
+
8
+ ```bash
9
+ ├─ lib
10
+ │ ├─ appydave
11
+ │ │ └─ tools
12
+ │ │ ├─ bank_reconciliation
13
+ │ │ │ ├─ clean
14
+ │ │ │ │ ├─ read_transactions.rb
15
+ │ │ │ │ ├─ transaction_cleaner.rb
16
+ │ │ │ ├─ models
17
+ │ │ │ │ ├─ raw_transaction.rb
18
+ │ │ │ │ └─ reconciled_transaction.rb
19
+ │ │ └─ configuration
20
+ │ │ └─ models
21
+ │ │ └─ bank_reconciliation_config.rb
22
+ └─ spec
23
+ ├─ appydave
24
+ │ ├─ tools
25
+ │ │ ├─ bank_reconciliation
26
+ │ │ │ ├─ clean
27
+ │ │ │ │ ├─ read_transactions_spec.rb
28
+ │ │ │ ├─ models
29
+ │ │ │ │ └─ raw_transaction_spec.rb
30
+ │ │ └─ configuration
31
+ │ │ └─ models
32
+ │ │ └─ bank_reconciliation_config_spec.rb
33
+ └─ fixtures
34
+ └─ bank-reconciliation
35
+ └─ bank-west.csv
36
+ ```
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'clipboard'
4
+
5
+ module Appydave
6
+ module Tools
7
+ module BankReconciliation
8
+ module Clean
9
+ # Clean transactions
10
+ class CleanTransactions
11
+ include KLog::Logging
12
+ include Appydave::Tools::Configuration::Configurable
13
+ include Appydave::Tools::Debuggable
14
+
15
+ attr_reader :transaction_folder
16
+ attr_reader :output_folder
17
+ attr_reader :transactions
18
+
19
+ # (config_file)
20
+ def initialize(transaction_folder: nil, output_folder: nil, debug: false)
21
+ @debug = debug
22
+ # needs to use config.bank_reconciliation.transaction_folder
23
+ transaction_folder ||= '/Volumes/Expansion/Sync/bank-reconciliation/original-transactions'
24
+ output_folder ||= File.join(transaction_folder, 'clean')
25
+
26
+ @transaction_folder = transaction_folder
27
+ @output_folder = output_folder
28
+ end
29
+
30
+ def clean_transactions(input_globs, output_file)
31
+ log_info("Starting transaction cleaning with input patterns: #{input_globs}")
32
+
33
+ raw_transactions = grab_raw_transactions(input_globs)
34
+ log_info("Total raw transactions collected: #{raw_transactions.size}")
35
+
36
+ transactions, duplicates_count = deduplicate_across_files(raw_transactions)
37
+ log_info("Duplicates found and removed: #{duplicates_count}")
38
+
39
+ transactions = Mapper.new.map(transactions)
40
+ log_info('Transactions mapped to chart of accounts and bank accounts')
41
+
42
+ log_kv 'Deduped consolidated transactions', duplicates_count if duplicates_count.positive?
43
+
44
+ save_to_csv(transactions, output_file)
45
+
46
+ csv_to_clipboard(output_file)
47
+
48
+ @transactions = transactions
49
+ end
50
+
51
+ private
52
+
53
+ def grab_raw_transactions(input_globs)
54
+ original_dir = Dir.pwd
55
+ transactions = []
56
+
57
+ begin
58
+ Dir.chdir(transaction_folder)
59
+
60
+ input_globs.each do |glob|
61
+ Dir.glob(glob).each do |file|
62
+ log_kv 'Reading transactions from', file
63
+ raw_transactions = ReadTransactions.new(file).read
64
+ deduped_transactions, duplicates_count = deduplicate_within_file(raw_transactions)
65
+
66
+ if duplicates_count.positive?
67
+ log_kv 'Duplicates count within file', duplicates_count
68
+ log_kv 'File', file
69
+ end
70
+
71
+ transactions += deduped_transactions
72
+ end
73
+ end
74
+ ensure
75
+ Dir.chdir(original_dir)
76
+ end
77
+
78
+ transactions
79
+ end
80
+
81
+ def deduplicate_within_file(transactions)
82
+ unique_transactions = transactions.uniq do |transaction|
83
+ [
84
+ transaction.bsb_number,
85
+ transaction.account_number,
86
+ transaction.transaction_date,
87
+ transaction.narration,
88
+ transaction.cheque_number,
89
+ transaction.debit,
90
+ transaction.credit,
91
+ transaction.balance,
92
+ transaction.transaction_type
93
+ ]
94
+ end
95
+
96
+ duplicates = transactions.size - unique_transactions.size
97
+
98
+ [unique_transactions, duplicates]
99
+ end
100
+
101
+ def deduplicate_across_files(transactions)
102
+ grouped_transactions = transactions.group_by do |transaction|
103
+ [
104
+ transaction.bsb_number,
105
+ transaction.account_number,
106
+ transaction.transaction_date,
107
+ transaction.narration,
108
+ transaction.cheque_number,
109
+ transaction.debit,
110
+ transaction.credit,
111
+ transaction.balance,
112
+ transaction.transaction_type
113
+ ]
114
+ end
115
+
116
+ unique_transactions = []
117
+ duplicates_count = 0
118
+
119
+ grouped_transactions.each_value do |dupes|
120
+ unique_transaction = dupes.first
121
+ unique_transaction.source_files = dupes.flat_map(&:source_files).uniq
122
+ duplicates_count += dupes.size - 1
123
+ unique_transactions << unique_transaction
124
+ end
125
+
126
+ [unique_transactions, duplicates_count]
127
+ end
128
+
129
+ def full_output_file(output_file)
130
+ File.join(output_folder, output_file)
131
+ end
132
+
133
+ def save_to_csv(transactions, output_file)
134
+ FileUtils.mkdir_p(output_folder)
135
+ output_file = full_output_file(output_file)
136
+ # puts "Output file: #{output_file}"
137
+
138
+ # lets sort the transactions by the by the transaction date which is formated as 2023-07-10
139
+ transactions.sort_by!(&:transaction_date)
140
+
141
+ CSV.open(output_file, 'w') do |csv|
142
+ csv << Appydave::Tools::BankReconciliation::Models::Transaction.csv_headers
143
+ transactions.each do |transaction|
144
+ csv << transaction.to_csv_row
145
+ end
146
+ end
147
+
148
+ log_kv('Transaction Output File', full_output_file(output_file))
149
+ end
150
+
151
+ def csv_to_clipboard(output_file)
152
+ Clipboard.copy(File.read(full_output_file(output_file)))
153
+ log_info('Transactions copied to clipboard')
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appydave
4
+ module Tools
5
+ module BankReconciliation
6
+ module Clean
7
+ # Map transactions to chart of accounts and bank accounts
8
+ class Mapper
9
+ include Appydave::Tools::Configuration::Configurable
10
+
11
+ def map(transactions)
12
+ # tp transactions, :coa_match_type, :coa_code, :narration, :debit, :credit
13
+ transactions.map do |original_transaction|
14
+ transaction = original_transaction.dup
15
+
16
+ transaction = map_chart_of_account(transaction)
17
+ clean_amount(transaction)
18
+ map_bank_account(transaction)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def map_chart_of_account(transaction)
25
+ equality_match(transaction) ||
26
+ trigram_match(transaction, 0.9, '90%') ||
27
+ trigram_match(transaction, 0.8, '80%') ||
28
+ trigram_match(transaction, 0.7, '70%') ||
29
+ trigram_match(transaction, 0.6, '60%') ||
30
+ trigram_match(transaction, 0.5, '50%') ||
31
+ start_with_match(transaction) ||
32
+ includes(transaction)
33
+ transaction
34
+ end
35
+
36
+ def map_bank_account(transaction)
37
+ bank_account = config.bank_reconciliation.get_bank_account(transaction.account_number, transaction.bsb_number)
38
+
39
+ if bank_account
40
+ transaction.account_name = bank_account.name
41
+ transaction.platform = bank_account.platform
42
+ else
43
+ puts "Bank account not found for BSB#: #{transaction.bsb_number} | Account#: #{transaction.account_number}"
44
+ end
45
+
46
+ transaction
47
+ end
48
+
49
+ def equality_match(transaction)
50
+ coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
51
+ chart_of_account.narration.to_s.delete(' ').downcase == transaction.narration.delete(' ').downcase
52
+ end
53
+
54
+ return nil unless coa
55
+
56
+ transaction.coa_match_type = 'equality'
57
+ transaction.coa_code = coa.code
58
+ transaction
59
+ end
60
+
61
+ def start_with_match(transaction)
62
+ coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
63
+ transaction.narration.to_s.delete(' ').downcase.start_with?(chart_of_account.narration.to_s.downcase)
64
+ end
65
+
66
+ return nil unless coa
67
+
68
+ transaction.coa_match_type = 'starts_with'
69
+ transaction.coa_code = coa.code
70
+ transaction
71
+ end
72
+
73
+ def includes(transaction)
74
+ coa = config.bank_reconciliation.chart_of_accounts.find do |chart_of_account|
75
+ transaction.narration.to_s.delete(' ').downcase.include?(chart_of_account.narration.delete(' ').to_s.downcase)
76
+ end
77
+
78
+ return nil unless coa
79
+
80
+ transaction.coa_match_type = 'includes'
81
+ transaction.coa_code = coa.code
82
+ transaction
83
+ end
84
+
85
+ def trigram_match(transaction, score_threshold, match_type)
86
+ scored_transactions = config.bank_reconciliation.chart_of_accounts.map do |coa|
87
+ {
88
+ coa: coa,
89
+ score: compare(coa.narration, transaction.narration)
90
+ }
91
+ end
92
+
93
+ scored_transactions.sort_by! { |t| t[:score] }.reverse!
94
+
95
+ best = scored_transactions.first
96
+
97
+ return nil unless best
98
+ return nil if best[:score] < score_threshold
99
+
100
+ coa = best[:coa]
101
+
102
+ transaction.coa_match_type = match_type
103
+ transaction.coa_code = coa.code
104
+ transaction
105
+ end
106
+
107
+ def compare(text1, text2)
108
+ text1_trigs = trigramify(text1)
109
+ text2_trigs = trigramify(text2)
110
+
111
+ all_cnt = (text1_trigs | text2_trigs).size
112
+ same_cnt = (text1_trigs & text2_trigs).size
113
+
114
+ same_cnt.to_f / all_cnt
115
+ # rescue StandardError => e
116
+ # puts "Error comparing text: #{e.message}"
117
+ end
118
+
119
+ def trigramify(text)
120
+ trigs = []
121
+ text.chars.each_cons(3) { |v| trigs << v.join }
122
+ trigs
123
+ end
124
+
125
+ def clean_amount(transaction)
126
+ transaction.debit = transaction.clean_amount(transaction.debit)
127
+ transaction.credit = transaction.clean_amount(transaction.credit)
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end