ecoportal-api-graphql 2.2.1 → 3.0.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +405 -9
  3. data/README.gem.md +53 -0
  4. data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
  5. data/lib/ecoportal/api/common/graphql/client.rb +38 -0
  6. data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
  7. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
  8. data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
  9. data/lib/ecoportal/api/graphql/base.rb +1 -0
  10. data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
  11. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
  12. data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
  13. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +6 -1
  14. data/lib/ecoportal/api/graphql/input/page/update.rb +110 -7
  15. data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
  16. data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
  17. data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
  18. data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
  19. data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
  20. data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
  21. data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
  22. data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
  23. data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
  24. data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
  25. data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
  26. data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
  27. data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
  28. data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
  29. data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
  30. data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
  31. data/lib/ecoportal/api/graphql/model/template.rb +33 -0
  32. data/lib/ecoportal/api/graphql/model.rb +1 -0
  33. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
  34. data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
  35. data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
  36. data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
  37. data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
  38. data/lib/ecoportal/api/graphql/mutation.rb +1 -0
  39. data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
  40. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  41. data/lib/ecoportal/api/graphql_version.rb +1 -1
  42. metadata +35 -2
  43. data/README.md +0 -24
@@ -1,26 +1,174 @@
1
1
  module Ecoportal::API::GraphQL::Base::Page
2
2
  class DataField
3
3
  # Image gallery field.
4
- # ★ WRITE PATH BROKEN (schema-invalid, live-verified 2026-08): `ImageGalleryInput` has no
5
- # `fileContainerIds` — it takes `images: [ImageInput]`, attaching via `sourceId`, which is
6
- # an `Enzyme::ImageReference::TempImage` (a DIFFERENT upload pipeline than file containers).
7
- # Fixing this needs its own design — see the DataField shape-asymmetry review project.
8
- # The reader is equally stale: live responses carry `images`, not `fileContainers`.
4
+ #
5
+ # ★ FIXED 2026-09 (previously broken -- see `spec/.../image_gallery_characterization_spec.rb`
6
+ # git history for the prior "WRITE PATH BROKEN" documentation of this defect). Confirmed
7
+ # against the LIVE ecoPortal server source (read-only, /tmp/work/ecoPortal) that a
8
+ # gallery image is genuinely NOT a `FileContainer`:
9
+ # * `app/graphql/types/fill_in_page/inputs/data_fields/image_gallery_input.rb:5-11` --
10
+ # `ImageGalleryInput` has NO `fileContainerIds`; it takes `images: [ImageInput]`.
11
+ # * `.../image_galleries/image_input.rb:6-15` -- `ImageInput` takes `sourceId` (a
12
+ # `TempImage` id, from the SEPARATE `uploadImage` mutation -- see
13
+ # `Mutation::Image::Upload` / `FileUpload::Client#upload_image`), never
14
+ # `fileContainerId`.
15
+ # * `app/services/new_ep/pages/assign_attributes_service.rb:152-176`
16
+ # (`set_image_gallery_attrs`) -- full-replace-by-omission, the SAME trap as
17
+ # `FileField`: any current image whose `id` is not echoed back in the write is
18
+ # destroyed (`format_removed_ids`).
19
+ # * `app/graphql/types/pages/data_fields/image_galleries/image_type.rb` -- `fileName`/
20
+ # `fileSize` are NULLABLE even on a successful upload -- a documented upstream bug
21
+ # ("images are successfully uploaded but the file name and size are not set in the
22
+ # database", per the live schema's own field comment). Any comparison built on these
23
+ # two fields must treat a nil on either side as "not a confident match", never a
24
+ # positive one.
25
+ #
26
+ # READ: `images { id weight downloadUrl caption fileName fileSize uploadId }`
27
+ # (`imageGalleryField` fragment, `fragment/pages/common_page_union.rb` -- `id`/`weight`
28
+ # were ADDED alongside this fix; the fragment previously omitted both, which is why no
29
+ # correct reader was possible before now).
30
+ #
31
+ # WRITE: `ImageGalleryInput { id images: [ImageInput] }`; `ImageInput { id weight
32
+ # sourceId caption fileName sensitiveContent inaccurateDescription
33
+ # inaccurateExtractedText }`. `as_input` sends every KEPT image back with its FULL
34
+ # `ImageInput` field set (`#kept_image_input`) -- NOT a bare `{id:}` -- and every NEW one
35
+ # as `{sourceId:, weight:, fileName:}` -- see `#add_source_images`, the merge-safe writer
36
+ # (mirrors `FileField#file_container_ids=`'s kept-vs-new split).
37
+ #
38
+ # ★ CORRECTED 2026-09 (pre-release, MR !278 unreleased): the bare `{id:}` echo for a kept
39
+ # image, above, silently dropped every OTHER attribute on that image on the NEXT write
40
+ # server-side -- `assign_attributes_service.rb#set_image_gallery_attrs` applies `images`
41
+ # as a FULL REPLACE (see the class header above), so a bare `{id:}` is indistinguishable
42
+ # from "clear caption/sensitiveContent/etc back to their defaults" for that image, not
43
+ # "leave it as it is". Matches the org-side reference implementation (a downstream script repo,
44
+ # commit 482db9c) and the live schema's own `ImageInput` argument list -- the shipped
45
+ # `ep-api-collections` sample only demonstrates the NEW-image shape (no existing images to
46
+ # keep), so this shape is pinned to server source + that proven reference, not a captured
47
+ # kept-echo payload (see EVIDENCE PRECEDENCE note on `#kept_image_input`).
48
+ #
49
+ # `images` is deliberately a PLAIN doc reader, not `passarray` -- same reasoning as
50
+ # `FileField#items` (see that class's header): materialising an ArrayModel writes
51
+ # `images: []` into the doc of a field read without `$content`, and a phantom-dirty empty
52
+ # `images` here doesn't always no-op (only the empty-vs-missing-key case is neutralised
53
+ # by `Diffable::LeafDiffService`'s array-diff fix, MR !106) -- it can still synthesise an
54
+ # unwanted write for a non-empty phantom read. A plain reader has no such hazard.
9
55
  class ImageGallery < DataField
10
- passarray :fileContainers # each: { id:, fileName:, url:, ... }
56
+ # each: { 'id' =>, 'weight' =>, 'downloadUrl' =>, 'caption' =>, 'fileName' =>,
57
+ # 'fileSize' =>, 'uploadId' => }
58
+ def images
59
+ Array(doc['images'])
60
+ end
61
+
62
+ # @return [Array<String>] every image's own item id (its server-assigned id, NOT the
63
+ # `uploadId`/`TempImage` id it was created from).
64
+ def image_ids
65
+ images.filter_map { |image| image['id'] if image.is_a?(Hash) }
66
+ end
11
67
 
12
- def file_container_ids
13
- Array(fileContainers).map { |c| c.is_a?(Hash) ? c['id'] : c }.compact
68
+ # @return [Integer] one past the highest `weight` currently on the field (`0` if the
69
+ # field is empty). There is no server-side auto-increment for `weight` (a plain
70
+ # Integer field, default `0`) -- callers that want sequential ordering for several new
71
+ # images in one write should still call this ONCE and increment locally
72
+ # (`#add_source_images` already does).
73
+ def next_weight
74
+ return 0 if images.empty?
75
+
76
+ images.map { |image| image.is_a?(Hash) ? image['weight'].to_i : 0 }.max + 1
14
77
  end
15
78
 
16
- def file_container_ids=(ids)
17
- doc['fileContainers'] = Array(ids).map { |id_val| { 'id' => id_val } }
79
+ # Merge-safe write: adds NEW images (each referenced by `source_id`, the `TempImage`
80
+ # id from `FileUpload::Client#upload_image`) while KEEPING every image already loaded
81
+ # on this field. `images` on `ImageGalleryInput` is a FULL REPLACE server-side -- any
82
+ # current image whose `id` is not echoed back is destroyed -- so this always keeps
83
+ # every currently-loaded image's FULL doc entry (untouched -- `#images_input` picks the
84
+ # `ImageInput`-writable subset out of it later; kept HERE in full so that subset still
85
+ # has every field to read, rather than collapsing to a bare `{'id' => ...}` that would
86
+ # throw the other attributes away before `#as_input` ever runs) and appends the new
87
+ # ones after it, at sequential weights starting at `#next_weight`. Marks the field dirty
88
+ # (same underlying mechanism as `FileField#file_container_ids=` -- a raw `doc` mutation,
89
+ # picked up by the generic diff regardless of the `passarray`/plain-reader choice above).
90
+ #
91
+ # @param sources [Array<Hash>] one entry per NEW image. Accepts either key style:
92
+ # `{source_id:, file_name:}` or `{'sourceId' =>, 'fileName' =>}`. `weight` is assigned
93
+ # here, not read from the input -- the caller does not need to compute it.
94
+ # @return [Array<Hash>] the full resulting `images` doc (kept + added), same value now
95
+ # readable via `#images`.
96
+ def add_source_images(sources)
97
+ kept = images.select { |image| image.is_a?(Hash) && image['id'] }
98
+ start = next_weight
99
+ added = Array(sources).each_with_index.map do |source, index|
100
+ {
101
+ 'sourceId' => fetch_key(source, :source_id, 'sourceId'),
102
+ 'weight' => start + index,
103
+ 'fileName' => fetch_key(source, :file_name, 'fileName')
104
+ }.compact
105
+ end
106
+ doc['images'] = kept + added
18
107
  end
19
108
 
20
109
  def as_input
21
110
  return nil unless dirty?
22
111
 
23
- { imageGallery: { id: id, fileContainerIds: file_container_ids } }
112
+ {imageGallery: {id: id, images: images_input}}
113
+ end
114
+
115
+ private
116
+
117
+ # `images` holds the write shape for a NEW item (`{'sourceId' =>, 'weight' =>,
118
+ # 'fileName' =>}`, from `#add_source_images`) as-is, and the FULL read doc for a KEPT
119
+ # item (untouched since the last read) -- `#kept_image_input` narrows the latter down to
120
+ # exactly what `ImageInput` accepts.
121
+ def images_input
122
+ images.filter_map do |image|
123
+ next unless image.is_a?(Hash)
124
+
125
+ if image['sourceId']
126
+ {sourceId: image['sourceId'], weight: image['weight'], fileName: image['fileName']}.compact
127
+ elsif image['id']
128
+ kept_image_input(image)
129
+ end
130
+ end
131
+ end
132
+
133
+ # Full-field echo for a KEPT (currently-existing, untouched) image -- REQUIRED because
134
+ # `ImageGalleryInput.images` is a full replace server-side (`assign_attributes_service.
135
+ # rb#set_image_gallery_attrs` -> `format_removed_ids`): a bare `{id:}` is read by the
136
+ # server as "clear every other attribute", not "leave this image as it is".
137
+ #
138
+ # EVIDENCE PRECEDENCE (shipped sample -> server source -> gem model): the shipped
139
+ # a customer collection's "Attach Image to Gallery Field"
140
+ # sample only demonstrates a BRAND-NEW image (`sourceId`/`weight`/`fileName`, no
141
+ # existing images on the field to keep) -- it does not capture a kept-image echo. This
142
+ # shape is therefore pinned to the next level down: the live schema's own `ImageInput`
143
+ # argument list (`app/graphql/types/fill_in_page/inputs/data_fields/image_galleries/
144
+ # image_input.rb`: `id weight sourceId caption fileName sensitiveContent
145
+ # inaccurateDescription inaccurateExtractedText`) plus the org-side reference
146
+ # implementation already built against those same server facts and merged
147
+ # (a downstream script repo, commit 482db9c, `#kept_gallery_image_input`) -- not an inference
148
+ # from this gem's own prior (incorrect) behaviour.
149
+ #
150
+ # `sourceId` here is read back from the CURRENT read's `uploadId` -- the read-side name
151
+ # for the same identifier (a customer collection's integration guide
152
+ # section 7: "the id changes name between reading and writing"). Every key is sent as
153
+ # read (never `.compact`-ed away) -- this is an ECHO of the field's current state, so a
154
+ # nil here means "this attribute is genuinely unset", which is exactly what should be
155
+ # (re-)written to preserve it unset, not omitted and misread as "clear it".
156
+ def kept_image_input(image)
157
+ {
158
+ id: image['id'],
159
+ sourceId: image['uploadId'],
160
+ weight: image['weight'],
161
+ caption: image['caption'],
162
+ fileName: image['fileName'],
163
+ sensitiveContent: image['sensitiveContent'],
164
+ inaccurateDescription: image['inaccurateDescription'],
165
+ inaccurateExtractedText: image['inaccurateExtractedText']
166
+ }
167
+ end
168
+
169
+ def fetch_key(hash, *keys)
170
+ keys.each { |k| return hash[k] if hash.is_a?(Hash) && hash.key?(k) }
171
+ nil
24
172
  end
25
173
  end
26
174
  end
@@ -0,0 +1,28 @@
1
+ module Ecoportal
2
+ module API
3
+ class GraphQL
4
+ module Base
5
+ # The upload-staging model behind `uploadImage` -- explicitly NOT a `FileContainer`.
6
+ # Verified against the live ecoPortal server schema (read-only,
7
+ # /tmp/work/ecoPortal): `Types::Files::TempImageType` (`app/graphql/types/files/
8
+ # temp_image_type.rb`) declares exactly `id s3Key error complete`, backed by
9
+ # `Enzyme::ImageReference::TempImage` (`app/models/enzyme/image_reference.rb`), a
10
+ # separate upload-staging Mongoid model from `FileContainer`.
11
+ #
12
+ # `complete` starts `false` immediately after `uploadImage` returns and is filled in
13
+ # later by background processing. A consumer does NOT need to wait for it: confirmed
14
+ # server-side (`app/services/new_ep/pages/assign_attributes_service.rb`'s
15
+ # `set_image_gallery_attrs`) that attaching a `TempImage` to an Image Gallery field via
16
+ # `ImageInput.sourceId` succeeds whether or not `complete?`/`error?` is true yet -- an
17
+ # incomplete image is accepted and filled in on the record later.
18
+ class TempImage < Logic::BaseModel
19
+ read_only!
20
+
21
+ passkey :id
22
+ passthrough :s3Key, :error
23
+ passboolean :complete
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -15,6 +15,7 @@ require_relative 'base/location_structure'
15
15
  require_relative 'base/person_member'
16
16
  require_relative 'base/file_attachment'
17
17
  require_relative 'base/file_container'
18
+ require_relative 'base/temp_image'
18
19
  require_relative 'base/field'
19
20
  require_relative 'base/page'
20
21
  require_relative 'base/resource'
@@ -9,12 +9,17 @@ module Ecoportal
9
9
  @client = client
10
10
  end
11
11
 
12
- def create(commands:, &block)
13
- createMutation.query(commands: commands, &block)
12
+ # @param register_id [String, nil] bind the new template to this register. Optional in
13
+ # the schema, but in practice REQUIRED for the create to be authorized at all — see
14
+ # the WHY in {Mutation::Template::Create}. Omitted when nil.
15
+ def create(commands:, register_id: nil, &block)
16
+ createMutation.query(commands: commands, register_id: register_id, &block)
14
17
  end
15
18
 
16
- def update(model, commands:, patch_ver: model.patchVer, &block)
17
- updateMutation.query(id: model.id, patch_ver: patch_ver, commands: commands, &block)
19
+ def update(model, commands:, patch_ver: model.patchVer, client_mutation_id: nil, &block)
20
+ kwargs = { id: model.id, patch_ver: patch_ver, commands: commands }
21
+ kwargs[:client_mutation_id] = client_mutation_id unless client_mutation_id.nil?
22
+ updateMutation.query(**kwargs, &block)
18
23
  end
19
24
 
20
25
  def publish(id:, &block)
@@ -18,7 +18,7 @@ module Ecoportal
18
18
  # tag_filter passed through (same ES backend, same format)
19
19
  # register_filter passed through
20
20
  # and_filter / or_filter passed through
21
- # Operation/param shapes per the repo's internal docs.
21
+ # Operation/param shapes per the repo's internal docs
22
22
  class FilterTranslator
23
23
  class << self
24
24
  def to_graphql(filter)
@@ -26,21 +26,38 @@ module Ecoportal
26
26
  # `X-ECOPORTAL-API-KEY` is needed here: every ecoPortal call is GraphQL, on the session
27
27
  # token this client already holds.
28
28
  #
29
- # Single file:
29
+ # ★ Extended 2026-09 with `#upload_image`/`#upload_all_images` — steps 1-2 above (the
30
+ # presign + S3 POST) are IDENTICAL for an Image Gallery image; only step 3 differs
31
+ # (`uploadImage` -> a `TempImage`, not `uploadFile` -> a `FileContainer`). Confirmed
32
+ # against the live ecoPortal server source (read-only, /tmp/work/ecoPortal) that an
33
+ # Image Gallery image is genuinely NOT a `FileContainer` — see
34
+ # `Mutation::Image::Upload`'s and `Base::Page::DataField::ImageGallery`'s own headers
35
+ # for the full evidence trail. `#presign_and_store!` is the shared internal path both
36
+ # `#upload_one` and `#upload_one_image` call; `#run_batch` is the shared batch-with-
37
+ # progress-callback loop both `#upload_all` and `#upload_all_images` call.
38
+ #
39
+ # Single file, FileContainer (File-type fields):
30
40
  # id = api.file_upload.upload('/path/report.pdf')
31
41
  # page.components.get_by_name('Report').file_container_ids = [id]
32
42
  #
33
- # Many files, concurrently, with per-file error isolation (nothing raises out):
43
+ # Single file, TempImage (Image Gallery fields):
44
+ # source_id = api.file_upload.upload_image('/path/photo.jpg')
45
+ # page.components.get_by_name('Site Photos').add_source_images([{source_id: source_id, file_name: 'photo.jpg'}])
46
+ #
47
+ # Many files, concurrently, with per-file error isolation (nothing raises out) — same
48
+ # shape for both flows:
34
49
  # results = api.file_upload.upload_all(paths, threads: 4) do |r|
35
50
  # puts r.success? ? "#{r.file} -> #{r.container_id}" : "#{r.file} FAILED: #{r.error}"
36
51
  # end
37
52
  # results.select(&:error?)
38
53
  #
39
- # Instrumentation / middleware — hooks fire per stage, per file:
54
+ # Instrumentation / middleware — hooks fire per stage, per file, for EITHER flow (the
55
+ # stage names are shared: `:register` fires once whether the registration mutation was
56
+ # `uploadFile` or `uploadImage`):
40
57
  # client = api.file_upload
41
58
  # client.on(:signature) { |creds| logger.info "presigned #{creds.endpoint}" }
42
59
  # client.on(:storage) { |key, response| logger.info "S3 #{response.code} #{key}" }
43
- # client.on(:register) { |payload| logger.info "container #{payload.item&.id}" }
60
+ # client.on(:register) { |payload| logger.info "registered #{payload.item&.id}" }
44
61
  class Client
45
62
  include Ecoportal::API::GraphQL::Concerns::Threadable
46
63
 
@@ -51,9 +68,10 @@ module Ecoportal
51
68
 
52
69
  STAGES = %i[signature storage register].freeze
53
70
  # Fallback only — the real value is a condition inside the returned policy.
54
- DEFAULT_ENCRYPTION = 'AES256'.freeze
55
- DEFAULT_MIME = 'application/octet-stream'.freeze
56
- MAX_THREADS = 8
71
+ DEFAULT_ENCRYPTION = 'AES256'.freeze
72
+ DEFAULT_MIME = 'application/octet-stream'.freeze
73
+ DEFAULT_IMAGE_TYPE = 'image_gallery'.freeze
74
+ MAX_THREADS = 8
57
75
 
58
76
  # One entry per input file. Mirrors `ecoportal-api-v2`'s
59
77
  # `S3::Files::BatchUpload::FileResult` so batch code reads the same in both stacks.
@@ -77,13 +95,36 @@ module Ecoportal
77
95
  end
78
96
  end
79
97
 
98
+ # Sibling to {Result} for the image-upload pipeline — same shape, different item
99
+ # (a `TempImage`, not a `FileContainer`).
100
+ ImageResult = Struct.new(:file) do
101
+ attr_accessor :key, :payload, :error
102
+
103
+ def error?
104
+ !error.nil?
105
+ end
106
+
107
+ def success?
108
+ !error? && !temp_image_id.nil?
109
+ end
110
+
111
+ def temp_image
112
+ payload&.item
113
+ end
114
+
115
+ def temp_image_id
116
+ temp_image&.id
117
+ end
118
+ end
119
+
80
120
  def initialize(graphql_client)
81
121
  @graphql = graphql_client
82
122
  @hooks = {}
83
123
  end
84
124
 
85
125
  # Register a stage hook. Called for every file, in that file's own thread — keep it
86
- # thread-safe (or wrap it in your own mutex).
126
+ # thread-safe (or wrap it in your own mutex). Fires identically for {#upload} and
127
+ # {#upload_image} — the STAGE names are shared between both flows.
87
128
  # @param stage [:signature, :storage, :register]
88
129
  def on(stage, &block)
89
130
  stage = stage.to_sym
@@ -102,33 +143,32 @@ module Ecoportal
102
143
  result.container_id
103
144
  end
104
145
 
146
+ # @return [String] the new `TempImage` id (the `sourceId` an Image Gallery
147
+ # `ImageInput` write needs — see `Base::Page::DataField::ImageGallery#
148
+ # add_source_images`).
149
+ # @raise [Error] on any failure — use {#upload_all_images} for non-raising per-file
150
+ # isolation.
151
+ def upload_image(file_path, **kargs)
152
+ result = upload_one_image(file_path, **kargs)
153
+ raise result.error if result.error?
154
+
155
+ result.temp_image_id
156
+ end
157
+
105
158
  # Uploads many files with bounded concurrency. Never raises for a single file: each
106
159
  # {Result} carries its own error, and the block is called as each finishes.
107
160
  #
108
161
  # @param threads [Integer] max concurrent uploads (`1` = inline, deterministic).
109
162
  # @return [Array<Result>] one per input file; order is not guaranteed when threaded.
110
163
  def upload_all(file_paths, threads: 4, **kargs, &block)
111
- files = Array(file_paths).flatten.compact
112
- max = threads.to_i.clamp(1, MAX_THREADS)
113
- results = []
114
- spawned = []
115
-
116
- # Presign ONCE for the batch (the policy is time-boxed but reusable) and warm it
117
- # here so N threads don't race for the first one.
118
- credentials
119
-
120
- with_preserved_thread_globals do
121
- files.each do |file|
122
- new_thread(spawned, max: max) do
123
- result = upload_one(file, **kargs)
124
- mutex(:results).synchronize { results << result }
125
- block&.call(result)
126
- end
127
- end
128
- end
164
+ run_batch(file_paths, threads: threads, on_result: block) { |file| upload_one(file, **kargs) }
165
+ end
129
166
 
130
- spawned.each(&:join)
131
- results
167
+ # Sibling to {#upload_all} for the image-upload pipeline — identical concurrency,
168
+ # isolation and progress-callback semantics.
169
+ # @return [Array<ImageResult>]
170
+ def upload_all_images(file_paths, threads: 4, **kargs, &block)
171
+ run_batch(file_paths, threads: threads, on_result: block) { |file| upload_one_image(file, **kargs) }
132
172
  end
133
173
 
134
174
  # Force the next upload to presign again (e.g. after a policy expiry).
@@ -141,13 +181,10 @@ module Ecoportal
141
181
 
142
182
  def upload_one(file_path, location_ids: nil, tags: nil, container_id: nil)
143
183
  Result.new(file_path).tap do |result|
144
- raise MissingLocalFile, "no such file: #{file_path}" unless ::File.file?(file_path)
145
-
146
- creds = credentials
147
- result.key = storage_key(creds, file_path)
148
- store!(creds, result.key, file_path)
184
+ _creds, key = presign_and_store!(file_path)
185
+ result.key = key
149
186
  result.payload = register(
150
- file_path, result.key,
187
+ file_path, key,
151
188
  location_ids: location_ids, tags: tags, container_id: container_id
152
189
  )
153
190
  rescue StandardError => e
@@ -155,6 +192,61 @@ module Ecoportal
155
192
  end
156
193
  end
157
194
 
195
+ def upload_one_image(file_path, type: DEFAULT_IMAGE_TYPE, id: nil, import: true)
196
+ ImageResult.new(file_path).tap do |result|
197
+ _creds, key = presign_and_store!(file_path)
198
+ result.key = key
199
+ result.payload = register_image(file_path, key, type: type, id: id, import: import)
200
+ rescue StandardError => e
201
+ result.error = e
202
+ end
203
+ end
204
+
205
+ # Shared by BOTH flows: presign (memoised per batch), build a collision-proof S3
206
+ # key, and the multipart POST itself. Only the THIRD step (the registration
207
+ # mutation — `uploadFile` vs `uploadImage`) differs between {#upload_one} and
208
+ # {#upload_one_image}.
209
+ # @return [Array(Query::FileUploadSignature::SignatureResponse, String)] the
210
+ # credentials used and the S3 key the file was stored under.
211
+ def presign_and_store!(file_path)
212
+ raise MissingLocalFile, "no such file: #{file_path}" unless ::File.file?(file_path)
213
+
214
+ creds = credentials
215
+ key = storage_key(creds, file_path)
216
+ store!(creds, key, file_path)
217
+ [creds, key]
218
+ end
219
+
220
+ # Shared by {#upload_all}/{#upload_all_images}: bounded-concurrency loop, presign-
221
+ # once-per-batch, per-file error isolation, and a progress callback fired as each
222
+ # result lands (not deferred to the end). `upload_one` (the block) is the only thing
223
+ # that differs between the two callers.
224
+ # @yieldparam file_path [String]
225
+ # @yieldreturn [Result, ImageResult]
226
+ def run_batch(file_paths, threads:, on_result: nil)
227
+ files = Array(file_paths).flatten.compact
228
+ max = threads.to_i.clamp(1, MAX_THREADS)
229
+ results = []
230
+ spawned = []
231
+
232
+ # Presign ONCE for the batch (the policy is time-boxed but reusable) and warm it
233
+ # here so N threads don't race for the first one.
234
+ credentials
235
+
236
+ with_preserved_thread_globals do
237
+ files.each do |file|
238
+ new_thread(spawned, max: max) do
239
+ result = yield(file)
240
+ mutex(:results).synchronize { results << result }
241
+ on_result&.call(result)
242
+ end
243
+ end
244
+ end
245
+
246
+ spawned.each(&:join)
247
+ results
248
+ end
249
+
158
250
  # Memoised behind a mutex so a batch presigns once, not once per thread.
159
251
  def credentials
160
252
  mutex(:credentials).synchronize do
@@ -167,7 +259,8 @@ module Ecoportal
167
259
  # `uploads/<userId>/<epoch-ms>-<token>-/<basename>` — the shape the web client uses.
168
260
  # That stamp+token segment is what stops same-named files colliding: the previous
169
261
  # implementation concatenated `upload_prefix + filename`, so two uploads of
170
- # `report.pdf` overwrote each other in the bucket.
262
+ # `report.pdf` overwrote each other in the bucket. Shared by BOTH flows — an image
263
+ # upload needs the identical collision-proofing.
171
264
  def storage_key(creds, file_path)
172
265
  stamp = (Time.now.to_f * 1000).to_i
173
266
  "#{creds.upload_prefix}#{stamp}-#{SecureRandom.alphanumeric(20)}-/#{::File.basename(file_path)}"
@@ -224,6 +317,18 @@ module Ecoportal
224
317
  payload
225
318
  end
226
319
 
320
+ def register_image(file_path, key, type:, id:, import:)
321
+ payload = Mutation::Image::Upload.new(@graphql.client).query(
322
+ key: key, type: type, id: id, import: import
323
+ )
324
+ fire(:register, payload)
325
+
326
+ raise RegistrationFailed, "uploadImage failed for #{::File.basename(file_path)}: #{payload.error_doc}" if payload.error?
327
+ raise RegistrationFailed, "uploadImage returned no item for #{::File.basename(file_path)}" unless payload.item&.id
328
+
329
+ payload
330
+ end
331
+
227
332
  # The bucket policy dictates the encryption header, so echo it instead of assuming:
228
333
  # the policy is base64 JSON whose `conditions` carry `x-amz-server-side-encryption`.
229
334
  def encryption(creds)
@@ -243,11 +243,16 @@ module Ecoportal
243
243
 
244
244
  fragment imageGalleryField on ImageGallery {
245
245
  images @include(if: $content) {
246
+ id
247
+ weight
246
248
  downloadUrl
247
249
  caption
248
250
  fileName
249
251
  fileSize
250
- uploadId @skip(if: $only_content)
252
+ uploadId @skip(if: $only_content)
253
+ sensitiveContent @skip(if: $only_content)
254
+ inaccurateDescription @skip(if: $only_content)
255
+ inaccurateExtractedText @skip(if: $only_content)
251
256
  }
252
257
  }
253
258