eco-helpers 3.2.13 → 3.2.16

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/conventions/code-working-tree-protocol.md +176 -0
  3. data/.ai-assistance/scripts/token-logger.js +220 -0
  4. data/.ai-assistance/scripts/token-report.ts +158 -0
  5. data/.ai-assistance/scripts/token-session-start.js +66 -0
  6. data/.ai-assistance/skills/ep-ai-manager/SKILL.md +417 -0
  7. data/.ai-assistance/skills/ruby-scripting/SKILL.md +215 -0
  8. data/.ai-assistance/standards-version.json +10 -0
  9. data/.ai-assistance/token-budget.json +39 -0
  10. data/.claude/settings.json +103 -0
  11. data/.gitignore +2 -0
  12. data/CHANGELOG.md +29 -1
  13. data/CLAUDE.md +83 -0
  14. data/eco-helpers.gemspec +1 -1
  15. data/lib/eco/api/usecases/CLAUDE.md +78 -0
  16. data/lib/eco/api/usecases/default/pages.rb +30 -0
  17. data/lib/eco/api/usecases/default/utils/add_page_id_case.rb +273 -0
  18. data/lib/eco/api/usecases/default/utils/cli/add_page_id_cli.rb +29 -0
  19. data/lib/eco/api/usecases/default/utils/cli/group_csv_cli.rb +5 -0
  20. data/lib/eco/api/usecases/default/utils/cli/track_files_cli.rb +16 -0
  21. data/lib/eco/api/usecases/default/utils/group_csv_case/file_handler.rb +62 -0
  22. data/lib/eco/api/usecases/default/utils/group_csv_case.rb +64 -22
  23. data/lib/eco/api/usecases/default/utils/track_files_case.rb +179 -0
  24. data/lib/eco/api/usecases/default/utils.rb +2 -0
  25. data/lib/eco/api/usecases/graphql/CLAUDE.md +120 -0
  26. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/dirty_array.rb +22 -0
  27. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/field_patches.rb +241 -0
  28. data/lib/eco/api/usecases/graphql/compat/ooze_redirect/force_compat.rb +73 -0
  29. data/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb +234 -0
  30. data/lib/eco/api/usecases/graphql/compat.rb +6 -0
  31. data/lib/eco/api/usecases/graphql/helpers/CLAUDE.md +79 -0
  32. data/lib/eco/api/usecases/graphql/samples/CLAUDE.md +76 -0
  33. data/lib/eco/api/usecases/graphql/samples/pages/CLAUDE.md +59 -0
  34. data/lib/eco/api/usecases/graphql/samples/pages/org_page/base.rb +41 -0
  35. data/lib/eco/api/usecases/graphql/samples/pages/org_page/dsl.rb +8 -0
  36. data/lib/eco/api/usecases/graphql/samples/pages/org_page.rb +7 -0
  37. data/lib/eco/api/usecases/graphql/samples/pages/page/base.rb +148 -0
  38. data/lib/eco/api/usecases/graphql/samples/pages/page/dsl.rb +38 -0
  39. data/lib/eco/api/usecases/graphql/samples/pages/page.rb +7 -0
  40. data/lib/eco/api/usecases/graphql/samples/pages.rb +7 -0
  41. data/lib/eco/api/usecases/graphql/samples.rb +1 -0
  42. data/lib/eco/api/usecases/graphql.rb +1 -0
  43. data/lib/eco/api/usecases/ooze_samples/ooze_base_case.rb +4 -0
  44. data/lib/eco/api/usecases/ooze_samples/register_update_case.rb +7 -1
  45. data/lib/eco/version.rb +1 -1
  46. metadata +36 -3
@@ -0,0 +1,62 @@
1
+ class Eco::API::UseCases::Default::Utils::GroupCsv
2
+ class FileHandler
3
+ attr_reader :filename, :format
4
+
5
+ def initialize(filename, format: :csv)
6
+ @filename = filename
7
+ @format = format
8
+
9
+ open
10
+ end
11
+
12
+ def <<(value)
13
+ msg = "File has been closed. Can't write to it: #{filename}"
14
+ raise msg unless file
15
+
16
+ case format
17
+ when :csv
18
+ file << value
19
+ when :jsonl
20
+ file.puts to_s(value)
21
+ end
22
+ end
23
+
24
+ def close
25
+ return if file.nil?
26
+
27
+ file.close.tap do
28
+ @file = nil
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :file
35
+
36
+ def to_s(value)
37
+ case value
38
+ when String
39
+ value.split("\n").first.tap do |line|
40
+ next if line == value
41
+
42
+ raise ArgumentError, "As string, value should be a single line. Given: #{value}"
43
+ end
44
+ when Hash
45
+ value.to_json
46
+ else
47
+ raise ArgumentError, "Unsupported type: #{value.class}"
48
+ end
49
+ end
50
+
51
+ def open
52
+ case format
53
+ when :csv
54
+ @file = CSV.open(filename, 'wb')
55
+ when :jsonl
56
+ @file = File.open(filename, 'wb')
57
+ else
58
+ raise "Unknown output format: #{format}"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -1,35 +1,59 @@
1
1
  # This script assumes that for the `GROUP_BY_FIELD` rows are consecutive.
2
2
  # @note you might run first the `sort-csv` case.
3
+ # @note when using `jsonl` as an output `format`, it doesn't merge fields,
4
+ # but it groups them based on some criteria.
5
+ # - In this case you need to define a `json_builder` method that returns a hash.
3
6
  # @note you must inherit from this case and define the constants.
4
7
  #
5
- # GROUP_BY_FIELD = 'target_csv_field'.freeze
8
+ # GROUP_BY_FIELD = 'target_csv_field'.freeze # if `-by` command option isn't used
6
9
  # GROUPED_FIELDS = [
7
10
  # 'joined_field_1',
8
11
  # 'joined_field_2',
9
12
  # 'joined_field_3',
10
13
  # ].freeze
11
- #
14
+ # @note that `GROUPED_FIELDS` isn't necessary if `jsonl` is used as an output `format`
12
15
  class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
13
16
  name 'group-csv'
14
17
  type :other
15
18
 
16
19
  require_relative 'cli/group_csv_cli'
20
+ require_relative 'group_csv_case/file_handler'
21
+
22
+ OUTPUT_FORMAT = :csv # :csv or :jsonl
17
23
 
18
24
  def main(*_args)
19
25
  if simulate?
20
26
  count = Eco::CSV.count(input_file)
21
27
  log(:info) { "CSV '#{input_file}' has #{count} rows." }
22
28
  else
29
+ msg = "You should define a json_builder method when using jsonl as output format"
30
+ raise msg unless respond_to?(:json_builder, true) || output_format != :jsonl
31
+
23
32
  generate_file
24
33
  end
25
34
  end
26
35
 
27
36
  private
28
37
 
38
+ attr_reader :in_index
39
+
40
+ def with_output_file
41
+ handler = FileHandler.new(output_filename, format: output_format)
42
+
43
+ yield handler
44
+ ensure
45
+ handler&.close
46
+
47
+ msg = "Generated file '#{output_filename}' "
48
+ msg << "with #{row_count} rows (out of #{in_index + 1})."
49
+
50
+ log(:info) { msg } unless simulate?
51
+ end
52
+
29
53
  def generate_file # rubocop:disable Metrics/AbcSize
30
- in_index = nil
54
+ @in_index = nil
31
55
 
32
- CSV.open(output_filename, 'wb') do |out_csv|
56
+ with_output_file do |f_handler|
33
57
  first = true
34
58
 
35
59
  puts "\n"
@@ -38,11 +62,11 @@ class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
38
62
  if first
39
63
  first = false
40
64
  headers!(row)
41
- out_csv << headers
65
+ f_handler << headers if output_format == :csv
42
66
  require_group_by_field!(row, file: input_file)
43
67
  end
44
68
 
45
- in_index = idx
69
+ @in_index = idx
46
70
  next unless !block_given? || yield(row, idx)
47
71
 
48
72
  next unless pivotable?(row, idx)
@@ -50,19 +74,25 @@ class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
50
74
 
51
75
  row_count!
52
76
 
53
- out_csv << last_group.values_at(*headers)
77
+ case output_format
78
+ when :csv
79
+ f_handler << last_group.values_at(*headers)
80
+ when :jsonl
81
+ f_handler << json_builder(last_group)
82
+ end
54
83
  end
55
84
 
56
85
  # finalize
57
86
  if (l_row = pivot_row)
58
87
  row_count!
59
- out_csv << l_row.values_at(*headers)
60
- end
61
- ensure
62
- msg = "Generated file '#{output_filename}' "
63
- msg << "with #{row_count} rows (out of #{in_index + 1})."
64
88
 
65
- log(:info) { msg } unless simulate?
89
+ case output_format
90
+ when :csv
91
+ f_handler << l_row.values_at(*headers)
92
+ when :jsonl
93
+ f_handler << json_builder(l_row)
94
+ end
95
+ end
66
96
  end
67
97
  end
68
98
 
@@ -76,16 +106,23 @@ class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
76
106
  pivot_value = row[group_by_field]
77
107
 
78
108
  unless (last_pivot = @group[group_by_field])
109
+ # init
79
110
  last_pivot = @group[group_by_field] = pivot_value
80
111
  end
81
112
 
82
113
  last = @group
83
114
  @group = {group_by_field => pivot_value} unless pivot_value == last_pivot
84
115
 
85
- headers_rest.each do |field|
86
- curr_values = row[field].to_s.split('|').compact.uniq
87
- pivot_values = @group[field].to_s.split('|').compact.uniq
88
- @group[field] = (pivot_values | curr_values).join('|')
116
+ case output_format
117
+ when :csv
118
+ headers_rest.each do |field|
119
+ curr_values = row[field].to_s.split('|').compact.uniq
120
+ group_values = @group[field].to_s.split('|').compact.uniq
121
+ @group[field] = (group_values | curr_values).join('|')
122
+ end
123
+ when :jsonl
124
+ @group['rows'] ||= []
125
+ @group['rows'] << row.to_h.slice(*headers_rest)
89
126
  end
90
127
 
91
128
  last unless last == @group
@@ -97,9 +134,10 @@ class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
97
134
  def headers!(row)
98
135
  return if headers?
99
136
 
100
- @headers_rest = grouped_fields & row.headers
101
- @headers_rest -= [group_by_field]
102
- @headers = [group_by_field, *headers_rest]
137
+ @grouped_fields = row.headers - [group_by_field] if output_format == :jsonl
138
+ @headers_rest = grouped_fields & row.headers
139
+ @headers_rest -= [group_by_field]
140
+ @headers = [group_by_field, *headers_rest]
103
141
  end
104
142
 
105
143
  def headers?
@@ -108,7 +146,7 @@ class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
108
146
 
109
147
  def row_count!
110
148
  @row_count ||= 0
111
- (@row_count += 1).tap do |cnt|
149
+ (@row_count += 1).tap do |cnt|
112
150
  if (cnt % 500).zero?
113
151
  print "... Done #{cnt} rows \r"
114
152
  $stdout.flush
@@ -141,10 +179,14 @@ class Eco::API::UseCases::Default::Utils::GroupCsv < Eco::API::Custom::UseCase
141
179
  num
142
180
  end
143
181
 
182
+ def output_format
183
+ options.dig(:output, :format)&.to_sym || self.class::OUTPUT_FORMAT
184
+ end
185
+
144
186
  def output_filename
145
187
  return unless input_name
146
188
 
147
- File.join(input_dir, "#{input_name}_grouped#{input_ext}")
189
+ File.join(input_dir, "#{input_name}_grouped.#{output_format}")
148
190
  end
149
191
 
150
192
  def input_name
@@ -0,0 +1,179 @@
1
+ # Tracks the files of a source folder into a file
2
+ class Eco::API::UseCases::Default::Utils::TrackFiles < Eco::API::Custom::UseCase
3
+ name 'track-files'
4
+ type :other
5
+
6
+ require_relative 'cli/track_files_cli'
7
+
8
+ OUT_HEADERS = %w[
9
+ ref_id
10
+ filename
11
+ filesize
12
+ s3_path
13
+ ].freeze
14
+
15
+ REF_ID_PATH_POSITION = :last
16
+ BASE_S3_PATH = 'uploads'.freeze
17
+ # S3_SUBPATH = 'org-name'.freeze
18
+
19
+ def main(*_args)
20
+ if simulate?
21
+ count_files
22
+ else
23
+ generate_file
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :folder_count, :file_count
30
+
31
+ def folder_count!(cnt = 1)
32
+ @folder_count ||= 0
33
+
34
+ print '.'
35
+ @folder_count += cnt
36
+ end
37
+
38
+ def file_count!(cnt = 1)
39
+ @file_count ||= 0
40
+ @file_count += cnt
41
+ end
42
+
43
+ def count_files
44
+ with_each_file
45
+
46
+ log(:info) {
47
+ "Found #{file_count} files, in #{folder_count} folders (with files)."
48
+ }
49
+ end
50
+
51
+ def ref_id_path_position
52
+ self.class::REF_ID_PATH_POSITION
53
+ end
54
+
55
+ def generate_file
56
+ CSV.open(output_filename, 'wb') do |csv|
57
+ csv << self.class::OUT_HEADERS
58
+
59
+ with_each_file do |file, src_path|
60
+ ref_id =
61
+ case ref_id_path_position
62
+ when :first then src_path.first
63
+ when :last then src_path.last
64
+ else
65
+ raise ArgumentError, "Unknown REF_ID_PATH_POSITION: #{ref_id_path_position} "
66
+ end
67
+
68
+ file_row = [ref_id]
69
+ file_row << file_name = File.basename(file)
70
+ file_row << File.size(file)
71
+ file_row << s3_path(file_name, src_path)
72
+
73
+ csv << file_row
74
+ end
75
+ end
76
+ ensure
77
+ msg = "Generated file '#{output_filename}' "
78
+ msg << "with #{file_count} files/rows "
79
+ msg << "organized in #{folder_count} folders."
80
+
81
+ log(:info) { msg } unless simulate?
82
+ end
83
+
84
+ def with_each_file(folders = top_subfolders, src_path: [], &block)
85
+ folders.each do |folder|
86
+ folder_name = File.basename(folder)
87
+ path = src_path[0..-1]
88
+ path << folder_name
89
+
90
+ files = folder_files(folder)
91
+ subfolders = top_subfolders(folder)
92
+
93
+ next if files.empty? && subfolders.empty? # skip
94
+
95
+ if files.any? && subfolders.any?
96
+ msg = "Folder '#{folder}' contains both files and subfolders."
97
+ msg << "\nFor correctly tracking and handling file attachments, "
98
+ msg << "this is not supported."
99
+
100
+ raise ArgumentError, msg
101
+ end
102
+
103
+ unless files.empty?
104
+ folder_count!
105
+ file_count!(files.count)
106
+
107
+ files.each do |file|
108
+ yield(file, path) if block_given?
109
+ end
110
+ end
111
+
112
+ next if subfolders.empty?
113
+
114
+ with_each_file(
115
+ subfolders,
116
+ src_path: path,
117
+ &block
118
+ )
119
+ end
120
+ end
121
+
122
+ def s3_path(filename, path)
123
+ [
124
+ self.class::BASE_S3_PATH,
125
+ s3_subpath,
126
+ *path,
127
+ filename
128
+ ].compact.join('/')
129
+ end
130
+
131
+ def s3_subpath
132
+ options.dig(:output, :s3_path) ||
133
+ s3_subpath_const ||
134
+ config.active_enviro
135
+ end
136
+
137
+ def s3_subpath_const
138
+ self.class::S3_SUBPATH if self.class.const_defined?(:S3_SUBPATH)
139
+ end
140
+
141
+ def top_subfolders(base_folder = input_base_folder)
142
+ Dir[
143
+ File.join(base_folder, "*")
144
+ ].select do |f|
145
+ File.directory?(f)
146
+ end
147
+ end
148
+
149
+ def folder_files(dir)
150
+ Dir[
151
+ File.join(dir, "*")
152
+ ].select do |f|
153
+ File.file?(f)
154
+ end
155
+ end
156
+
157
+ def output_filename
158
+ return unless input_folder_name
159
+
160
+ File.join(
161
+ config.active_enviro,
162
+ 'sftp',
163
+ "#{input_folder_name}_files.csv"
164
+ )
165
+ end
166
+
167
+ def input_folder_name
168
+ @input_folder_name ||= File.basename(input_base_folder)
169
+ end
170
+
171
+ def input_base_folder
172
+ options.dig(:input, :folder).tap do |folder|
173
+ next if File.directory?(folder)
174
+
175
+ msg = "Expecting '#{folder}' to be a directory, but it isn't."
176
+ raise ArgumentError, msg
177
+ end
178
+ end
179
+ end
@@ -16,3 +16,5 @@ require_relative 'utils/sort_csv_case'
16
16
  require_relative 'utils/group_csv_case'
17
17
  require_relative 'utils/merge_csv_case'
18
18
  require_relative 'utils/entries_to_csv_case'
19
+ require_relative 'utils/track_files_case'
20
+ require_relative 'utils/add_page_id_case'
@@ -0,0 +1,120 @@
1
+ # usecases/graphql
2
+
3
+ GraphQL-native use case base classes and helpers. All cases here work directly with
4
+ `ecoportal-api-graphql` — no v2 REST layer, no ooze objects.
5
+
6
+ ---
7
+
8
+ ## Class hierarchy
9
+
10
+ ```
11
+ Eco::API::Common::Loaders::UseCase (registration + launch)
12
+
13
+ Eco::API::UseCases::GraphQL::Base ← universal GraphQL env
14
+ ├── GraphQL::Samples::Pages::Page::Base ← register-scoped pages
15
+ │ ├── GraphQL::Samples::Pages::OrgPage::Base ← org-wide pages
16
+ │ └── your subclass (process_page, search_conf)
17
+ └── your subclass directly (custom scripts: exports, reports, one-offs)
18
+ ```
19
+
20
+ Samples live under `samples/pages/` — NOT in the `graphql/` root. The root only
21
+ has `base.rb`, `helpers.rb`, `utils.rb`, and `samples.rb`.
22
+
23
+ ---
24
+
25
+ ## Base — `graphql/base.rb`
26
+
27
+ Universal GraphQL environment. Provides `graphql`, `session`, `options`, `config`,
28
+ `simulate?`, `log`, `backup` via `Helpers::Base` (see `helpers/CLAUDE.md`).
29
+
30
+ Override `process` to write your script:
31
+ ```ruby
32
+ class MyCase < Eco::API::UseCases::GraphQL::Base
33
+ name 'my-case'
34
+ def process
35
+ graphql.currentOrganization.contractorEntities.each { |c| puts c.name }
36
+ end
37
+ end
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Pages — `samples/pages/`
43
+
44
+ Page processing base cases. Follow the hierarchy: `page/base` → `org_page/base`.
45
+
46
+ ### `samples/pages/page/base.rb` — `Samples::Pages::Page::Base`
47
+
48
+ For **register-scoped** page update workflows.
49
+
50
+ **Class methods:** `register_id 'REG_ID'`, `batch_size 50` (default)
51
+
52
+ **Override points:**
53
+ - `process_page(page)` — **required** — transformation for one page
54
+ - `search_conf` — optional — call `super` to keep register scope, then add filters
55
+
56
+ **Protected helpers:** `update_page`, `skip(reason)`, `each_page`
57
+
58
+ **KPI readers:** `total_pages`, `processed_pages`, `updated_pages`, `skipped_pages`, `failed_pages`
59
+
60
+ **DSL (via `samples/pages/page/dsl.rb`):** `sc`, `in_register`, `state_is`, `external_id_eq`, `updated_since`
61
+
62
+ ```ruby
63
+ class Custom::UseCase::UpdateStatus < Eco::API::UseCases::GraphQL::Samples::Pages::Page::Base
64
+ name 'update-status'
65
+ register_id 'REG_ABC'
66
+
67
+ def search_conf
68
+ super.filter(state_is(:active))
69
+ end
70
+
71
+ def process_page(page)
72
+ page.name = page.name.upcase
73
+ update_page(page)
74
+ end
75
+ end
76
+ ```
77
+
78
+ ### `samples/pages/org_page/base.rb` — `Samples::Pages::OrgPage::Base`
79
+
80
+ Inherits `Page::Base`. `search_conf` starts empty (org-wide, no register scope).
81
+ Use for: archive sweeps, cross-register audits, bulk org operations.
82
+
83
+ ---
84
+
85
+ ## Samples — `graphql/samples/`
86
+
87
+ Built-in ready-to-use case implementations:
88
+ - `samples/location.rb` — location structure management cases
89
+ - `samples/contractors.rb` — contractor entity cases
90
+
91
+ See `samples/CLAUDE.md` for details.
92
+
93
+ ---
94
+
95
+ ## Helpers — `graphql/helpers/`
96
+
97
+ Mixins providing domain-specific access patterns. See `helpers/CLAUDE.md`.
98
+
99
+ ---
100
+
101
+ ## Loader order in `graphql.rb`
102
+
103
+ ```ruby
104
+ require 'graphql/helpers' # environment mixins (graphql, session, simulate? etc.)
105
+ require 'graphql/utils' # utility modules (SFTP etc.)
106
+ require 'graphql/base' # GraphQL::Base — universal foundation
107
+ require 'graphql/samples' # sample cases: location, contractors, pages, ...
108
+ # └─ graphql/samples/pages.rb
109
+ # └─ pages/page.rb → page/dsl.rb, page/base.rb
110
+ # └─ pages/org_page.rb → org_page/dsl.rb, org_page/base.rb
111
+ ```
112
+
113
+ Page base cases are in `samples/pages/` — NOT in the `graphql/` root.
114
+ Custom org cases are NOT loaded here — they live in the implementation repo.
115
+
116
+ ## default/pages/
117
+
118
+ CLI-integrated page use cases go in `default/pages/` (mirroring `default/locations/`
119
+ and `default/people/`). Currently empty — add cases there when a pattern is common
120
+ enough to expose to all org environments. See `default/pages.rb` for the convention.
@@ -0,0 +1,22 @@
1
+ module Eco::API::UseCases::GraphQL::Compat::OozeRedirect
2
+ # Array subclass that calls the field's setter when elements are appended,
3
+ # ensuring GraphQL dirty-tracking fires on `fld.people_ids << value`.
4
+ class DirtyArray < Array
5
+ def initialize(field, data)
6
+ @field = field
7
+ super(Array(data))
8
+ end
9
+
10
+ def <<(value)
11
+ super
12
+ @field.people_ids = to_a
13
+ self
14
+ end
15
+
16
+ def push(*values)
17
+ super
18
+ @field.people_ids = to_a
19
+ self
20
+ end
21
+ end
22
+ end