mobility_active_storage 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +247 -0
- data/Rakefile +12 -0
- data/lib/mobility_active_storage/attribute_methods_exclusion.rb +49 -0
- data/lib/mobility_active_storage/backend.rb +61 -0
- data/lib/mobility_active_storage/backend_methods.rb +174 -0
- data/lib/mobility_active_storage/fallback_attached.rb +75 -0
- data/lib/mobility_active_storage/many_backend.rb +64 -0
- data/lib/mobility_active_storage/model.rb +85 -0
- data/lib/mobility_active_storage/version.rb +5 -0
- data/lib/mobility_active_storage.rb +23 -0
- data/sig/mobility_active_storage.rbs +49 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d4544d367fbdc080f61260f4706289e6fa009088ab73399ee5ee4efbe1597f2f
|
|
4
|
+
data.tar.gz: cb31f79f1ba41e277fe74bd2a707e3a861a756b1e6c4659434ae5dac4312a19c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 260385aedbb5bf84fa7c244a669b108fa3b6a7671d48228e70f617fa2656c53bf03fc90ee1993ee3d9513b26701226dad5572afdcea6be1dd53e00253eb8c941
|
|
7
|
+
data.tar.gz: 8ffb358c2898f8ae304c43f135e97ad4105e9fa97ba419b1c6af823e8afe26f621c85f343b2f092273441f777a1f1bc50d84089ddb440283dc7283005d2337bf
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
Pre-release fixes from a security review of the initial commit.
|
|
6
|
+
|
|
7
|
+
- A `fallback:` read option is now honoured only where the attribute actually configures
|
|
8
|
+
fallbacks. Previously `document(fallback: :ja)` cross-read another locale even on an attribute
|
|
9
|
+
declaring `fallbacks: false`, so the declaration was not enforceable by a caller forwarding
|
|
10
|
+
untrusted options. Matches Mobility, whose fallbacks plugin is inert when `fallbacks: false`.
|
|
11
|
+
- `with_attached_*` scopes now validate the current locale against the attribute's configured
|
|
12
|
+
locales, raising the same `MobilityActiveStorage::Error` as the read path instead of an
|
|
13
|
+
`ActiveRecord::AssociationNotFoundError` about a generated association name.
|
|
14
|
+
- The lazily built fallback proxy classes are now guarded by a mutex.
|
|
15
|
+
- Translated attachments are no longer merged into `attributes`, `translated_attributes` or
|
|
16
|
+
`attribute_names_for_serialization` by Mobility's `attribute_methods` plugin. The value there
|
|
17
|
+
was a live `ActiveStorage::Attached` proxy referencing the record, so `attributes.to_json` and
|
|
18
|
+
`as_json` raised `SystemStackError` and `Model.new(record.attributes)` raised `ArgumentError`.
|
|
19
|
+
Rails' own `has_one_attached` puts nothing in `attributes`; this now matches. (`attribute_methods:
|
|
20
|
+
false` is not a workaround -- Mobility 1.3.2 accepts and silently ignores it.)
|
|
21
|
+
- Querying a translated attachment now raises `MobilityActiveStorage::Error` with an explanation
|
|
22
|
+
instead of a `NoMethodError` from inside Mobility's query plugin.
|
|
23
|
+
- The test harness now enables `attribute_methods`, `query`, `dirty` and `fallthrough_accessors`,
|
|
24
|
+
which it previously did not, so the suite exercises a maximal Mobility configuration.
|
|
25
|
+
- Raised the `activerecord` / `activestorage` floor to `>= 7.2.3.2`, the earliest release clearing
|
|
26
|
+
every current Active Storage advisory. Rails 7.0 and 7.1 are end-of-life with no fix available for
|
|
27
|
+
those advisories, and this gem serves more attachments through exactly the affected paths.
|
|
28
|
+
- CI runs a Rails 7.2/8.0/latest matrix on Ruby 3.2 through 4.0, plus `bundler-audit`. Actions are
|
|
29
|
+
pinned by SHA.
|
|
30
|
+
- README: strong-parameters guidance for the per-locale writers, and Rails version security notes.
|
|
31
|
+
|
|
32
|
+
## [0.1.0] - 2026-08-07
|
|
33
|
+
|
|
34
|
+
- Initial release.
|
|
35
|
+
- `has_one_translated_attached` and `has_many_translated_attached` macros on Active Record models.
|
|
36
|
+
- `Mobility::Backends` registrations for `:active_storage` and `:active_storage_many`.
|
|
37
|
+
- Per-locale attachments stored under locale-suffixed Active Storage attachment names
|
|
38
|
+
(`document_en`, `document_fr`), requiring no migration.
|
|
39
|
+
- Opt-in per-attribute fallbacks, where reads resolve through the fallback chain while writes
|
|
40
|
+
stay in the current locale.
|
|
41
|
+
- `#{attribute}_locales` and a current-locale `with_attached_#{attribute}` scope.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"mobility_active_storage" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
|
|
4
|
+
|
|
5
|
+
* Participants will be tolerant of opposing views.
|
|
6
|
+
* Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
|
|
7
|
+
* When interpreting the words and actions of others, participants should always assume good intentions.
|
|
8
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
9
|
+
|
|
10
|
+
If you have any concerns about behaviour within this project, please contact us at ["barnaclebarnes@mac.com"](mailto:"barnaclebarnes@mac.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Glen Barnes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# mobility_active_storage
|
|
2
|
+
|
|
3
|
+
Translated [Active Storage](https://guides.rubyonrails.org/active_storage_overview.html)
|
|
4
|
+
attachments for [Mobility](https://github.com/shioyama/mobility).
|
|
5
|
+
|
|
6
|
+
Attach a different file per locale under a single attribute name:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
class Product < ApplicationRecord
|
|
10
|
+
extend Mobility
|
|
11
|
+
|
|
12
|
+
translates :title, :description, type: :string # ordinary Mobility text
|
|
13
|
+
has_one_translated_attached :document
|
|
14
|
+
has_many_translated_attached :photos
|
|
15
|
+
end
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
product.document.attach(io: pdf, filename: "manual.pdf", content_type: "application/pdf")
|
|
20
|
+
|
|
21
|
+
Mobility.with_locale(:fr) do
|
|
22
|
+
product.document.attach(io: pdf_fr, filename: "manuel.pdf", content_type: "application/pdf")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
product.document # => the :en file
|
|
26
|
+
Mobility.with_locale(:fr) { product.document } # => the :fr file
|
|
27
|
+
product.document_fr # => the :fr file
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## No migration required
|
|
31
|
+
|
|
32
|
+
Active Storage's `active_storage_attachments.name` column is a plain string, so each locale is
|
|
33
|
+
stored as an ordinary attachment whose name carries the locale:
|
|
34
|
+
|
|
35
|
+
| name | record_type | record_id | blob_id |
|
|
36
|
+
| ------------- | ----------- | --------- | ------- |
|
|
37
|
+
| `document_en` | Product | 1 | 10 |
|
|
38
|
+
| `document_fr` | Product | 1 | 11 |
|
|
39
|
+
|
|
40
|
+
Each is declared with a real `has_one_attached` / `has_many_attached` under the hood, so variants,
|
|
41
|
+
previews, direct uploads, `purge`, `dependent: :purge_later` on destroy, and the generated
|
|
42
|
+
`document_en_attachment` / `document_en_blob` / `with_attached_document_en` all keep working
|
|
43
|
+
exactly as Rails defines them.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Gemfile
|
|
49
|
+
gem "mobility_active_storage"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
You need Active Storage installed (`bin/rails active_storage:install`) and Mobility configured.
|
|
53
|
+
Mobility does not need any translation tables for attachments — if attachments are all you
|
|
54
|
+
translate, [installing Mobility without tables](https://github.com/shioyama/mobility#activerecord-rails)
|
|
55
|
+
is enough.
|
|
56
|
+
|
|
57
|
+
`extend Mobility` is optional: the macros extend the model for you if it isn't already.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
### Reading and writing
|
|
62
|
+
|
|
63
|
+
Everything Active Storage's own proxy supports works, scoped to the current locale:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
product.document.attach(uploaded_file)
|
|
67
|
+
product.document.attached? # => true
|
|
68
|
+
product.document.filename # => "manual.pdf"
|
|
69
|
+
product.document.purge # purges only the current locale's file
|
|
70
|
+
product.document = nil # detaches only the current locale's file
|
|
71
|
+
|
|
72
|
+
product.photos.attach(one, two) # appends, per locale
|
|
73
|
+
product.photos = [one] # replaces the current locale's collection
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Assignment works on unsaved records and in `create`, so form params flow through normally:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Product.create!(title: "Widget", document: params[:document])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Generated methods
|
|
83
|
+
|
|
84
|
+
For `has_one_translated_attached :document`:
|
|
85
|
+
|
|
86
|
+
| Method | Description |
|
|
87
|
+
| ----------------------------- | -------------------------------------------------------- |
|
|
88
|
+
| `document` | `ActiveStorage::Attached::One` for the current locale |
|
|
89
|
+
| `document=` | Attach/detach in the current locale |
|
|
90
|
+
| `document_en`, `document_fr` | A specific locale (dashed locales normalize: `pt_br`) |
|
|
91
|
+
| `document?` | Whether a file is attached in the current locale |
|
|
92
|
+
| `document_locales` | Locales that actually have an attachment, e.g. `[:en, :fr]` |
|
|
93
|
+
| `Product.with_attached_document` | Eager-loads whichever locale is current |
|
|
94
|
+
|
|
95
|
+
`has_many_translated_attached :photos` mirrors these with `ActiveStorage::Attached::Many`.
|
|
96
|
+
|
|
97
|
+
### Fallbacks
|
|
98
|
+
|
|
99
|
+
Off by default. Enable per attribute:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
has_one_translated_attached :document, fallbacks: true # I18n's chain, ending at the default locale
|
|
103
|
+
has_one_translated_attached :document, fallbacks: { fr: :en } # explicit
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Reads then resolve through the chain, while **writes always stay in the current locale** — so
|
|
107
|
+
attaching, purging or detaching under a fallback never touches the file you fell back to:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
product.document.attach(en_file) # :en has a file, :fr does not
|
|
111
|
+
|
|
112
|
+
Mobility.with_locale(:fr) do
|
|
113
|
+
product.document.filename # => the :en file
|
|
114
|
+
product.document(fallback: false).attached? # => false
|
|
115
|
+
product.document.attach(fr_file) # creates document_fr; document_en untouched
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Pass `fallback: :ja` or `fallback: [:ja, :en]` to override the chain for a single read. That
|
|
120
|
+
override only applies where fallbacks are configured — an attribute declaring no fallbacks cannot be
|
|
121
|
+
talked into one by a read option, so `fallbacks: false` is enforceable even if a caller forwards
|
|
122
|
+
untrusted options into the reader. This matches Mobility, whose fallbacks plugin is inert when
|
|
123
|
+
`fallbacks: false`.
|
|
124
|
+
|
|
125
|
+
### Options
|
|
126
|
+
|
|
127
|
+
Both macros accept:
|
|
128
|
+
|
|
129
|
+
- `fallbacks:` — `true`, or a hash such as `{ fr: :en }`. Default `false`.
|
|
130
|
+
- `locales:` — which locales to declare attachments for. Defaults to `Mobility.available_locales`.
|
|
131
|
+
- any remaining options are forwarded to Rails, e.g. `service:`, `strict_loading:`, `dependent:`.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
has_one_translated_attached :document, locales: %i[en fr], service: :s3, strict_loading: true
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Using the backend directly
|
|
138
|
+
|
|
139
|
+
The macros are sugar over ordinary Mobility backends, which you can also use directly:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
translates :document, backend: :active_storage
|
|
143
|
+
translates :photos, backend: :active_storage_many
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The macros additionally switch off the Mobility plugins that assume a scalar value. Declaring the
|
|
147
|
+
backend directly means doing that yourself, for whichever of them you have enabled:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
translates :document, backend: :active_storage, fallbacks: false, cache: false, dirty: false
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Passing an option for a plugin you have *not* enabled raises `Mobility::Pluggable::InvalidOptionKey`,
|
|
154
|
+
so pass only the ones that apply. Keeping attachments out of `attributes` is handled by the backend
|
|
155
|
+
itself, so it applies on this path too.
|
|
156
|
+
|
|
157
|
+
## Strong parameters
|
|
158
|
+
|
|
159
|
+
Each locale gets its own set of writers. `has_one_translated_attached :document` on a four-locale
|
|
160
|
+
app defines `document=`, `document_en=`, `document_fr=`, `document_ja=`, `document_pt_br=` (plus the
|
|
161
|
+
`_attachment=` / `_blob=` association writers Rails generates), and they all work through mass
|
|
162
|
+
assignment. That is inherent to `has_one_attached`, but the names are not obvious from the single
|
|
163
|
+
macro call in your model.
|
|
164
|
+
|
|
165
|
+
Permit only the bare attribute name, which routes through Mobility to the current locale:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
params.expect(product: [:title, :document, photos: []])
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Permit the `_<locale>` variants only where a locale switcher genuinely needs them. A blanket
|
|
172
|
+
`permit!`, or a filter matching `/\Adocument/`, would let a user overwrite a locale they were never
|
|
173
|
+
editing — and with fallbacks enabled, that changes what other locales serve too.
|
|
174
|
+
|
|
175
|
+
## Rails compatibility
|
|
176
|
+
|
|
177
|
+
Requires **Rails >= 7.2.3.2** and Ruby >= 3.2. Tested in CI against Rails 7.2, 8.0 and latest, on
|
|
178
|
+
Ruby 3.2 through 4.0.
|
|
179
|
+
|
|
180
|
+
The floor is deliberately a patch level rather than a minor version. Rails 7.0 and 7.1 are
|
|
181
|
+
end-of-life and carry Active Storage advisories with no fix available — path traversal and glob
|
|
182
|
+
injection in `DiskService`, a content-type bypass in direct uploads, and DoS via `Range` requests in
|
|
183
|
+
proxy mode. Since this gem's whole purpose is to serve more attachments through exactly those paths,
|
|
184
|
+
it does not support them. 7.2.3.2 is the earliest release that clears every current Active Storage
|
|
185
|
+
advisory; the equivalent for the 8.0 series is 8.0.5.1.
|
|
186
|
+
|
|
187
|
+
Run [bundler-audit](https://github.com/rubysec/bundler-audit) or Dependabot in your application to
|
|
188
|
+
stay ahead of new advisories.
|
|
189
|
+
|
|
190
|
+
## Notes and limitations
|
|
191
|
+
|
|
192
|
+
**Locales are read when the class body runs.** Attachments are declared for
|
|
193
|
+
`Mobility.available_locales` at that moment, so adding a locale needs a code reload. Pass
|
|
194
|
+
`locales:` to pin the set explicitly.
|
|
195
|
+
|
|
196
|
+
**Mobility's `fallbacks` plugin is disabled for these attributes**, and this gem implements
|
|
197
|
+
fallbacks itself. Mobility's plugin triggers on a `nil` read, but an attachment reader has to
|
|
198
|
+
return a proxy even when nothing is attached, or `product.document.attach(...)` would fail on a
|
|
199
|
+
record with no file yet.
|
|
200
|
+
|
|
201
|
+
**The `cache` plugin is disabled** for these attributes, since it would memoize a proxy resolved
|
|
202
|
+
through a fallback and keep returning the fallback locale's file after a later attach.
|
|
203
|
+
|
|
204
|
+
**Dirty tracking is not supported.** Mobility's `dirty` plugin compares scalar values; it is
|
|
205
|
+
disabled for these attributes. Active Storage's own `attachment_changes` still works.
|
|
206
|
+
|
|
207
|
+
**Attachment attributes are kept out of `attributes`.** Mobility's `attribute_methods` plugin
|
|
208
|
+
merges every translated attribute into `attributes`, `translated_attributes` and
|
|
209
|
+
`attribute_names_for_serialization`. For an attachment that value would be a live
|
|
210
|
+
`ActiveStorage::Attached` proxy holding a reference back to the record, which makes the hash
|
|
211
|
+
unserialisable (`attributes.to_json` and `as_json` recurse until the stack overflows) and unusable
|
|
212
|
+
for mass assignment (`Model.new(record.attributes)` raises `ArgumentError`, since Active Storage
|
|
213
|
+
rejects a proxy as an attachable). Rails' own `has_one_attached` puts nothing in `attributes`, and
|
|
214
|
+
neither does this gem — a proxy is not a serialisable value. Translated *text* attributes are
|
|
215
|
+
unaffected and still appear as Mobility intends.
|
|
216
|
+
|
|
217
|
+
Note that `attribute_methods: false` is not a workaround: Mobility 1.3.2 accepts the option and
|
|
218
|
+
silently ignores it, because the plugin's `initialize_hook` is gated on `dependencies_satisfied?`
|
|
219
|
+
rather than on the option value.
|
|
220
|
+
|
|
221
|
+
**Querying is not supported.** `Product.i18n.where(document: ...)` is not meaningful for
|
|
222
|
+
attachments — there is no comparable column, only rows in `active_storage_attachments`. The
|
|
223
|
+
backend raises `MobilityActiveStorage::Error` explaining this rather than failing obscurely inside
|
|
224
|
+
Arel. Query the attachments directly, filtering on `name` (for example `"document_en"`).
|
|
225
|
+
|
|
226
|
+
## Development
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
bin/setup
|
|
230
|
+
bundle exec rake # tests + rubocop
|
|
231
|
+
|
|
232
|
+
# against a specific Rails version
|
|
233
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle install
|
|
234
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.2.gemfile bundle exec rake test
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The suite boots a minimal `Rails::Application` in `test/test_helper.rb` against in-memory SQLite,
|
|
238
|
+
so there is no dummy app to maintain.
|
|
239
|
+
|
|
240
|
+
## Contributing
|
|
241
|
+
|
|
242
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
243
|
+
https://github.com/barnaclebarnes/mobility_active_storage.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
Available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MobilityActiveStorage
|
|
4
|
+
# Keeps translated attachments out of the attribute hashes.
|
|
5
|
+
#
|
|
6
|
+
# Mobility's attribute_methods plugin merges every translated attribute into +attributes+. For
|
|
7
|
+
# an attachment that value is a live +ActiveStorage::Attached+ proxy holding a reference back to
|
|
8
|
+
# the record, so the hash cannot be serialised (+to_json+ recurses until the stack blows) and
|
|
9
|
+
# cannot be fed back into +Model.new+ (Active Storage rejects a proxy as an attachable). Rails'
|
|
10
|
+
# own +has_one_attached+ puts nothing in +attributes+; this restores that.
|
|
11
|
+
#
|
|
12
|
+
# Overriding +translated_attributes+ also cleans +attributes+: the plugin defines the latter as
|
|
13
|
+
# +super.merge(translated_attributes)+, and that inner call dispatches from the record, so it
|
|
14
|
+
# reaches this module first.
|
|
15
|
+
#
|
|
16
|
+
# Prepended rather than included, which matters. +include+ places this module *behind* any
|
|
17
|
+
# Mobility module added afterwards, and re-including an already-included module does not move it
|
|
18
|
+
# forward -- so a second +translates+ call (a second attachment macro, or a later
|
|
19
|
+
# +translates :title+) would merge its name back in after the filter had run. A prepended module
|
|
20
|
+
# stays ahead of every later include. Verified against Ruby's method resolution, not assumed.
|
|
21
|
+
module AttributeMethodsExclusion
|
|
22
|
+
# Registers +attribute+ on +model_class+ and installs the filter. Called from the backends'
|
|
23
|
+
# setup blocks rather than from the macros, so that declaring the backend directly with
|
|
24
|
+
# `translates :document, backend: :active_storage` is covered too.
|
|
25
|
+
def self.install(model_class, attribute)
|
|
26
|
+
model_class.translated_attachment_attribute_names << attribute.to_s
|
|
27
|
+
|
|
28
|
+
# Nothing to filter unless the plugin that does the merging is loaded -- and `super` in
|
|
29
|
+
# #translated_attributes would have nothing to reach.
|
|
30
|
+
return unless Mobility.translations_class.included_plugins
|
|
31
|
+
.map { |plugin| Mobility::Plugins.lookup_name(plugin) }
|
|
32
|
+
.include?(:attribute_methods)
|
|
33
|
+
|
|
34
|
+
model_class.prepend(self)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def translated_attributes
|
|
38
|
+
super.except(*self.class.translated_attachment_attribute_names)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def attribute_names_for_serialization
|
|
44
|
+
return unless defined?(super)
|
|
45
|
+
|
|
46
|
+
super - self.class.translated_attachment_attribute_names
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "backend_methods"
|
|
4
|
+
|
|
5
|
+
module MobilityActiveStorage
|
|
6
|
+
# Mobility backend for a single translated Active Storage attachment.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# class Product < ApplicationRecord
|
|
10
|
+
# extend Mobility
|
|
11
|
+
# translates :document, backend: :active_storage
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# product.document.attach(io: file, filename: "en.pdf", content_type: "application/pdf")
|
|
15
|
+
# Mobility.with_locale(:fr) { product.document.attach(...) }
|
|
16
|
+
class Backend
|
|
17
|
+
include Mobility::Backend
|
|
18
|
+
include BackendMethods
|
|
19
|
+
|
|
20
|
+
setup do |attributes, options, backend_class|
|
|
21
|
+
attributes.each do |attribute|
|
|
22
|
+
options[:locales].each do |locale|
|
|
23
|
+
has_one_attached :"#{attribute}_#{Mobility.normalize_locale(locale)}",
|
|
24
|
+
**options[:attached_options]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Eager-loads whichever locale is current when the scope is evaluated. Validates the
|
|
28
|
+
# locale the same way the read path does.
|
|
29
|
+
scope :"with_attached_#{attribute}", lambda {
|
|
30
|
+
name = backend_class.attachment_name_for(attribute, Mobility.locale)
|
|
31
|
+
includes("#{name}_attachment": :blob)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
define_method(:"#{attribute}_locales") { mobility_backends[attribute].locales }
|
|
35
|
+
|
|
36
|
+
# An attachment proxy is not a serialisable value, and Rails does not put one in
|
|
37
|
+
# `attributes` either. Installed here rather than in the macro so that declaring
|
|
38
|
+
# this backend directly through `translates` is covered too.
|
|
39
|
+
AttributeMethodsExclusion.install(self, attribute)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def attached_class
|
|
46
|
+
ActiveStorage::Attached::One
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fallback_attached_class
|
|
50
|
+
FallbackAttached.one
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_change(name, value)
|
|
54
|
+
if value.nil? || value == ""
|
|
55
|
+
ActiveStorage::Attached::Changes::DeleteOne.new(name, model)
|
|
56
|
+
else
|
|
57
|
+
ActiveStorage::Attached::Changes::CreateOne.new(name, model, value)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "i18n/locale/fallbacks"
|
|
4
|
+
|
|
5
|
+
module MobilityActiveStorage
|
|
6
|
+
# Behaviour shared by the has_one and has_many attachment backends.
|
|
7
|
+
#
|
|
8
|
+
# A translated attachment is stored as N ordinary Active Storage attachments whose
|
|
9
|
+
# +name+ column carries the locale, e.g. +document_en+ and +document_fr+. Each one is
|
|
10
|
+
# declared with a real +has_one_attached+ / +has_many_attached+ in the backend's setup
|
|
11
|
+
# block, so Rails' own associations, upload callbacks, scopes and purge-on-destroy
|
|
12
|
+
# behaviour apply unchanged.
|
|
13
|
+
#
|
|
14
|
+
# Backends must +include Mobility::Backend+ *before* this module, so that the overrides here
|
|
15
|
+
# (notably +each_locale+ and +present?+) take precedence over Mobility::Backend's generic
|
|
16
|
+
# implementations.
|
|
17
|
+
module BackendMethods
|
|
18
|
+
def self.included(base)
|
|
19
|
+
raise Error, "#{base} must include Mobility::Backend before #{self}" unless base < Mobility::Backend
|
|
20
|
+
|
|
21
|
+
base.extend(ClassMethods)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Class-level configuration shared by both backends.
|
|
25
|
+
module ClassMethods
|
|
26
|
+
def valid_keys
|
|
27
|
+
%i[locales attachment_fallbacks attached_options]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure(options)
|
|
31
|
+
options[:locales] = Array(options[:locales] || Mobility.available_locales).map(&:to_sym)
|
|
32
|
+
options[:attached_options] ||= {}
|
|
33
|
+
options[:attachment_fallbacks] = build_fallbacks(options[:attachment_fallbacks])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Locale-suffixed attachment name, rejecting locales this attribute was not configured
|
|
37
|
+
# for. Shared by the read path and by the eager-loading scopes, so both fail the same way
|
|
38
|
+
# rather than the scope leaking an ActiveRecord::AssociationNotFoundError about a
|
|
39
|
+
# generated association name.
|
|
40
|
+
def attachment_name_for(attribute, locale)
|
|
41
|
+
normalized = Mobility.normalize_locale(locale)
|
|
42
|
+
return "#{attribute}_#{normalized}" if normalized_locales.include?(normalized)
|
|
43
|
+
|
|
44
|
+
raise Error, "#{model_class.name} has no translated #{attribute} attachment for " \
|
|
45
|
+
"locale #{locale.inspect}. Configured locales: " \
|
|
46
|
+
"#{options[:locales].map(&:to_s).join(", ")}."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def configured_locale?(locale)
|
|
50
|
+
normalized_locales.include?(Mobility.normalize_locale(locale))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Mobility's query plugin asks the backend for an Arel node so it can build a predicate.
|
|
54
|
+
# An attachment is a row in active_storage_attachments, not a comparable column value, so
|
|
55
|
+
# there is nothing meaningful to compare. Fail with an explanation rather than the
|
|
56
|
+
# NoMethodError a missing `[]` would otherwise produce.
|
|
57
|
+
def [](name, _locale)
|
|
58
|
+
raise Error, "cannot query translated attachment #{name} -- attachments are not " \
|
|
59
|
+
"comparable values. Query ActiveStorage::Attachment directly, filtering " \
|
|
60
|
+
"on name (for example \"#{name}_#{Mobility.normalize_locale}\")."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalized_locales
|
|
64
|
+
@normalized_locales ||= options[:locales].map { |locale| Mobility.normalize_locale(locale) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def build_fallbacks(option)
|
|
70
|
+
case option
|
|
71
|
+
when true then FallbackChain.new({})
|
|
72
|
+
when Hash then FallbackChain.new(option)
|
|
73
|
+
else false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolves a locale to the chain of locales to try, in order.
|
|
79
|
+
#
|
|
80
|
+
# Mirrors Mobility's own fallbacks plugin: an explicit map is honoured first, then I18n's
|
|
81
|
+
# configured fallbacks when the application has them enabled, and the default locale always
|
|
82
|
+
# terminates the chain. Resolved on each read so that changes to +I18n.default_locale+ or
|
|
83
|
+
# +I18n.fallbacks+ are picked up.
|
|
84
|
+
class FallbackChain
|
|
85
|
+
def initialize(map)
|
|
86
|
+
@fallbacks = I18n::Locale::Fallbacks.new(map)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def [](locale)
|
|
90
|
+
chain = @fallbacks[locale]
|
|
91
|
+
chain |= I18n.fallbacks[locale] if I18n.respond_to?(:fallbacks)
|
|
92
|
+
chain | [I18n.default_locale]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns the Active Storage proxy for +locale+.
|
|
97
|
+
#
|
|
98
|
+
# Always returns a proxy, never nil, so that +record.document.attach(...)+ works on a
|
|
99
|
+
# record with nothing attached yet. Falls back to another locale only when the attribute
|
|
100
|
+
# was configured with fallbacks and the current locale has nothing attached.
|
|
101
|
+
#
|
|
102
|
+
# @param [Symbol] locale
|
|
103
|
+
# @return [ActiveStorage::Attached::One, ActiveStorage::Attached::Many]
|
|
104
|
+
def read(locale, fallback: true, **kwargs)
|
|
105
|
+
proxy = attached(locale)
|
|
106
|
+
return proxy if proxy.attached?
|
|
107
|
+
|
|
108
|
+
# Mobility's convention: an explicitly requested locale never falls back.
|
|
109
|
+
return proxy if fallback == false || kwargs[:locale]
|
|
110
|
+
|
|
111
|
+
fallback_locale = fallback_chain(locale, fallback).find do |candidate|
|
|
112
|
+
configured?(candidate) && attached(candidate).attached?
|
|
113
|
+
end
|
|
114
|
+
return proxy unless fallback_locale
|
|
115
|
+
|
|
116
|
+
# Reads resolve through the fallback locale; writes stay in the requested locale.
|
|
117
|
+
fallback_attached_class.new(attachment_name(locale), model, attachment_name(fallback_locale))
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Stages the attachment change for +locale+.
|
|
121
|
+
#
|
|
122
|
+
# Deliberately mutates +attachment_changes+ rather than calling +attach+.
|
|
123
|
+
# +ActiveStorage::Attached::One#attach+ is implemented as
|
|
124
|
+
# +record.public_send("document_en=", attachable)+, and Mobility's locale accessors take
|
|
125
|
+
# precedence over Active Storage's generated writer -- so calling +attach+ here would
|
|
126
|
+
# re-enter this same method. Writing the change directly makes both entry paths converge.
|
|
127
|
+
def write(locale, value, **)
|
|
128
|
+
name = attachment_name(locale)
|
|
129
|
+
model.attachment_changes[name] = build_change(name, value)
|
|
130
|
+
value
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether the attribute has an attachment in +locale+ (honouring fallbacks).
|
|
134
|
+
def present?(locale, **options)
|
|
135
|
+
read(locale, **options).attached?
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Yields each configured locale that actually has an attachment.
|
|
139
|
+
def each_locale
|
|
140
|
+
options[:locales].each { |locale| yield locale if attached(locale).attached? }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The Active Storage proxy for +locale+, without any fallback handling.
|
|
144
|
+
def attached(locale)
|
|
145
|
+
@attached ||= {}
|
|
146
|
+
@attached[Mobility.normalize_locale(locale)] ||=
|
|
147
|
+
attached_class.new(attachment_name(locale), model)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def attachment_name(locale)
|
|
153
|
+
self.class.attachment_name_for(attribute, locale)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def configured?(locale)
|
|
157
|
+
self.class.configured_locale?(locale)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# +fallback: :fr+ or +fallback: [:fr, :es]+ overrides the configured chain for one read.
|
|
161
|
+
#
|
|
162
|
+
# An attribute that declares no fallbacks cannot be talked into one by a read option, so a
|
|
163
|
+
# +fallbacks: false+ declaration is enforceable: otherwise any reader that forwards
|
|
164
|
+
# caller-controlled options (a serializer, a GraphQL resolver) would become a cross-locale
|
|
165
|
+
# read. This matches Mobility, whose fallbacks plugin is inert when +fallbacks: false+.
|
|
166
|
+
def fallback_chain(locale, fallback)
|
|
167
|
+
fallbacks = options[:attachment_fallbacks]
|
|
168
|
+
return [] unless fallbacks
|
|
169
|
+
return Array(fallback) unless fallback == true
|
|
170
|
+
|
|
171
|
+
fallbacks[locale]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MobilityActiveStorage
|
|
4
|
+
# Attached proxies used when a read resolves through a fallback locale.
|
|
5
|
+
#
|
|
6
|
+
# Reads (+attached?+, +filename+, +url+, iteration) resolve to the fallback locale's
|
|
7
|
+
# attachment, but every write -- +attach+, +purge+, +purge_later+, +detach+ -- stays bound to
|
|
8
|
+
# the locale actually being read. Without that split, +product.document.attach(file)+ under a
|
|
9
|
+
# fallback would overwrite the fallback locale's file instead of creating one for the
|
|
10
|
+
# current locale.
|
|
11
|
+
#
|
|
12
|
+
# The classes are built lazily: Active Storage's classes are autoloaded by its engine, which
|
|
13
|
+
# has not necessarily run at the point this gem is required from a Gemfile.
|
|
14
|
+
module FallbackAttached
|
|
15
|
+
# Guards the lazy build. Two threads racing here would both +const_set+, emitting an
|
|
16
|
+
# "already initialized constant" warning and handing callers structurally distinct classes,
|
|
17
|
+
# which would then fail each other's +is_a?+ checks.
|
|
18
|
+
BUILD_MUTEX = Mutex.new
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def one
|
|
22
|
+
@one || BUILD_MUTEX.synchronize { @one ||= const_set(:One, build_one) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def many
|
|
26
|
+
@many || BUILD_MUTEX.synchronize { @many ||= const_set(:Many, build_many) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_one
|
|
32
|
+
build(::ActiveStorage::Attached::One) do
|
|
33
|
+
# The fallback locale's attachment stands in when this locale has none.
|
|
34
|
+
def attachment
|
|
35
|
+
own.attachment || record.public_send(:"#{fallback_name}_attachment")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_many
|
|
41
|
+
build(::ActiveStorage::Attached::Many) do
|
|
42
|
+
def attachments
|
|
43
|
+
own.attachments.presence || record.public_send(:"#{fallback_name}_attachments")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def blobs
|
|
47
|
+
own.blobs.presence || record.public_send(:"#{fallback_name}_blobs")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def build(plain_class, &reads)
|
|
53
|
+
Class.new(plain_class) do
|
|
54
|
+
attr_reader :fallback_name
|
|
55
|
+
|
|
56
|
+
define_method(:initialize) do |name, record, fallback_name|
|
|
57
|
+
super(name, record)
|
|
58
|
+
@fallback_name = fallback_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# This locale as an ordinary Active Storage proxy. Writes delegate here so that
|
|
62
|
+
# Rails' own logic runs, bound to this locale's attachment name.
|
|
63
|
+
define_method(:own) { plain_class.new(name, record) }
|
|
64
|
+
|
|
65
|
+
def attach(*attachables) = own.attach(*attachables)
|
|
66
|
+
def purge = own.purge
|
|
67
|
+
def purge_later = own.purge_later
|
|
68
|
+
def detach = own.detach
|
|
69
|
+
|
|
70
|
+
class_eval(&reads)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "backend_methods"
|
|
4
|
+
|
|
5
|
+
module MobilityActiveStorage
|
|
6
|
+
# Mobility backend for a translated collection of Active Storage attachments.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# class Product < ApplicationRecord
|
|
10
|
+
# extend Mobility
|
|
11
|
+
# translates :photos, backend: :active_storage_many
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# product.photos.attach(first, second)
|
|
15
|
+
# Mobility.with_locale(:fr) { product.photos.attach(other) }
|
|
16
|
+
class ManyBackend
|
|
17
|
+
include Mobility::Backend
|
|
18
|
+
include BackendMethods
|
|
19
|
+
|
|
20
|
+
setup do |attributes, options, backend_class|
|
|
21
|
+
attributes.each do |attribute|
|
|
22
|
+
options[:locales].each do |locale|
|
|
23
|
+
has_many_attached :"#{attribute}_#{Mobility.normalize_locale(locale)}",
|
|
24
|
+
**options[:attached_options]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
scope :"with_attached_#{attribute}", lambda {
|
|
28
|
+
name = backend_class.attachment_name_for(attribute, Mobility.locale)
|
|
29
|
+
includes("#{name}_attachments": :blob)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
define_method(:"#{attribute}_locales") { mobility_backends[attribute].locales }
|
|
33
|
+
|
|
34
|
+
# An attachment proxy is not a serialisable value, and Rails does not put one in
|
|
35
|
+
# `attributes` either. Installed here rather than in the macro so that declaring
|
|
36
|
+
# this backend directly through `translates` is covered too.
|
|
37
|
+
AttributeMethodsExclusion.install(self, attribute)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def attached_class
|
|
44
|
+
ActiveStorage::Attached::Many
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def fallback_attached_class
|
|
48
|
+
FallbackAttached.many
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Mirrors the writer that has_many_attached generates, including carrying pending uploads
|
|
52
|
+
# across successive assignments so `attach` can append to an unsaved record.
|
|
53
|
+
def build_change(name, value)
|
|
54
|
+
attachables = Array(value).compact_blank
|
|
55
|
+
|
|
56
|
+
return ActiveStorage::Attached::Changes::DeleteMany.new(name, model) if attachables.none?
|
|
57
|
+
|
|
58
|
+
ActiveStorage::Attached::Changes::CreateMany.new(
|
|
59
|
+
name, model, attachables,
|
|
60
|
+
pending_uploads: model.attachment_changes[name].try(:pending_uploads)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MobilityActiveStorage
|
|
4
|
+
# Class macros mixed into ActiveRecord::Base.
|
|
5
|
+
module Model
|
|
6
|
+
# Declares a single translated attachment.
|
|
7
|
+
#
|
|
8
|
+
# class Product < ApplicationRecord
|
|
9
|
+
# has_one_translated_attached :document
|
|
10
|
+
# has_one_translated_attached :manual, fallbacks: true, service: :s3
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# @param name [Symbol] attribute name
|
|
14
|
+
# @param fallbacks [Boolean, Hash] +true+ to fall back through I18n's chain, or an explicit
|
|
15
|
+
# hash such as <tt>{ fr: :en }</tt>. Defaults to no fallback.
|
|
16
|
+
# @param locales [Array<Symbol>, nil] locales to declare attachments for. Defaults to
|
|
17
|
+
# +Mobility.available_locales+ at the time the class body runs.
|
|
18
|
+
# @param attached_options [Hash] forwarded to +has_one_attached+ (+:service+,
|
|
19
|
+
# +:strict_loading+, +:dependent+).
|
|
20
|
+
def has_one_translated_attached(name, fallbacks: false, locales: nil, **attached_options)
|
|
21
|
+
translated_attachment(name, :active_storage, fallbacks, locales, attached_options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Declares a translated collection of attachments. Options match
|
|
25
|
+
# {#has_one_translated_attached}; +attached_options+ is forwarded to +has_many_attached+.
|
|
26
|
+
def has_many_translated_attached(name, fallbacks: false, locales: nil, **attached_options)
|
|
27
|
+
translated_attachment(name, :active_storage_many, fallbacks, locales, attached_options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Attribute names declared through the attachment macros, including any inherited from a
|
|
31
|
+
# superclass. Used to keep attachments out of the hashes Mobility's attribute_methods plugin
|
|
32
|
+
# builds; see {AttributeMethodsExclusion}.
|
|
33
|
+
def translated_attachment_attribute_names
|
|
34
|
+
@translated_attachment_attribute_names ||=
|
|
35
|
+
if superclass.respond_to?(:translated_attachment_attribute_names)
|
|
36
|
+
superclass.translated_attachment_attribute_names.dup
|
|
37
|
+
else
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def translated_attachment(name, backend, fallbacks, locales, attached_options)
|
|
45
|
+
extend Mobility unless singleton_class.include?(Mobility)
|
|
46
|
+
|
|
47
|
+
enabled = enabled_mobility_plugins
|
|
48
|
+
|
|
49
|
+
translates name, **{
|
|
50
|
+
backend: backend,
|
|
51
|
+
locales: locales,
|
|
52
|
+
attachment_fallbacks: fallbacks,
|
|
53
|
+
attached_options: attached_options
|
|
54
|
+
}.merge(plugin_overrides(locales, enabled))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Mobility plugins that assume a scalar value, and so must be turned off per attribute.
|
|
58
|
+
# Only keys for plugins actually enabled are returned: Mobility raises InvalidOptionKey for
|
|
59
|
+
# an option belonging to a plugin that is not loaded.
|
|
60
|
+
def plugin_overrides(locales, enabled)
|
|
61
|
+
overrides = {}
|
|
62
|
+
|
|
63
|
+
# The fallbacks plugin consumes the `fallback:` read option before the backend sees it,
|
|
64
|
+
# and triggers only on a nil read -- but an attachment reader must always return a proxy.
|
|
65
|
+
# This gem implements attachment fallbacks itself instead.
|
|
66
|
+
overrides[:fallbacks] = false if enabled.include?(:fallbacks)
|
|
67
|
+
|
|
68
|
+
# The cache plugin would memoize a proxy that was resolved through a fallback, so a later
|
|
69
|
+
# attach in the current locale would keep returning the fallback locale's file.
|
|
70
|
+
overrides[:cache] = false if enabled.include?(:cache)
|
|
71
|
+
|
|
72
|
+
# Dirty tracking compares scalar values; attachment proxies are not comparable.
|
|
73
|
+
overrides[:dirty] = false if enabled.include?(:dirty)
|
|
74
|
+
|
|
75
|
+
# Keep locale accessors in step with the locales we actually declared attachments for.
|
|
76
|
+
overrides[:locale_accessors] = locales if locales && enabled.include?(:locale_accessors)
|
|
77
|
+
|
|
78
|
+
overrides
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def enabled_mobility_plugins
|
|
82
|
+
Mobility.translations_class.included_plugins.map { |plugin| Mobility::Plugins.lookup_name(plugin) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mobility"
|
|
4
|
+
require "active_support"
|
|
5
|
+
|
|
6
|
+
require_relative "mobility_active_storage/version"
|
|
7
|
+
|
|
8
|
+
module MobilityActiveStorage
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "mobility_active_storage/attribute_methods_exclusion"
|
|
13
|
+
require_relative "mobility_active_storage/fallback_attached"
|
|
14
|
+
require_relative "mobility_active_storage/backend"
|
|
15
|
+
require_relative "mobility_active_storage/many_backend"
|
|
16
|
+
require_relative "mobility_active_storage/model"
|
|
17
|
+
|
|
18
|
+
Mobility::Backends.register_backend(:active_storage, MobilityActiveStorage::Backend)
|
|
19
|
+
Mobility::Backends.register_backend(:active_storage_many, MobilityActiveStorage::ManyBackend)
|
|
20
|
+
|
|
21
|
+
ActiveSupport.on_load(:active_record) do
|
|
22
|
+
extend MobilityActiveStorage::Model
|
|
23
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module MobilityActiveStorage
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
# Class macros mixed into ActiveRecord::Base.
|
|
8
|
+
module Model
|
|
9
|
+
def has_one_translated_attached: (
|
|
10
|
+
Symbol name,
|
|
11
|
+
?fallbacks: bool | Hash[Symbol, Symbol | Array[Symbol]],
|
|
12
|
+
?locales: Array[Symbol]?,
|
|
13
|
+
**untyped attached_options
|
|
14
|
+
) -> void
|
|
15
|
+
|
|
16
|
+
def has_many_translated_attached: (
|
|
17
|
+
Symbol name,
|
|
18
|
+
?fallbacks: bool | Hash[Symbol, Symbol | Array[Symbol]],
|
|
19
|
+
?locales: Array[Symbol]?,
|
|
20
|
+
**untyped attached_options
|
|
21
|
+
) -> void
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module BackendMethods
|
|
25
|
+
def read: (Symbol locale, ?fallback: bool | Symbol | Array[Symbol], **untyped) -> untyped
|
|
26
|
+
def write: (Symbol locale, untyped value, **untyped) -> untyped
|
|
27
|
+
def present?: (Symbol locale, **untyped) -> bool
|
|
28
|
+
def each_locale: () { (Symbol) -> void } -> void
|
|
29
|
+
def attached: (Symbol locale) -> untyped
|
|
30
|
+
|
|
31
|
+
class FallbackChain
|
|
32
|
+
def initialize: (Hash[Symbol, untyped] map) -> void
|
|
33
|
+
def []: (Symbol locale) -> Array[Symbol]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class Backend
|
|
38
|
+
include BackendMethods
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class ManyBackend
|
|
42
|
+
include BackendMethods
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
module FallbackAttached
|
|
46
|
+
def self.one: () -> Class
|
|
47
|
+
def self.many: () -> Class
|
|
48
|
+
end
|
|
49
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mobility_active_storage
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Glen Barnes
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 7.2.3.2
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 7.2.3.2
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activestorage
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 7.2.3.2
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 7.2.3.2
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: mobility
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.2'
|
|
47
|
+
- - "<"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '2.0'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '1.2'
|
|
57
|
+
- - "<"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '2.0'
|
|
60
|
+
description: |
|
|
61
|
+
Adds has_one_translated_attached and has_many_translated_attached to Active Record models,
|
|
62
|
+
letting a single attachment name hold a different file per locale via Mobility. Requires no
|
|
63
|
+
migration: each locale is stored as an ordinary Active Storage attachment whose name carries
|
|
64
|
+
the locale suffix.
|
|
65
|
+
email:
|
|
66
|
+
- barnaclebarnes@mac.com
|
|
67
|
+
executables: []
|
|
68
|
+
extensions: []
|
|
69
|
+
extra_rdoc_files: []
|
|
70
|
+
files:
|
|
71
|
+
- CHANGELOG.md
|
|
72
|
+
- CODE_OF_CONDUCT.md
|
|
73
|
+
- LICENSE.txt
|
|
74
|
+
- README.md
|
|
75
|
+
- Rakefile
|
|
76
|
+
- lib/mobility_active_storage.rb
|
|
77
|
+
- lib/mobility_active_storage/attribute_methods_exclusion.rb
|
|
78
|
+
- lib/mobility_active_storage/backend.rb
|
|
79
|
+
- lib/mobility_active_storage/backend_methods.rb
|
|
80
|
+
- lib/mobility_active_storage/fallback_attached.rb
|
|
81
|
+
- lib/mobility_active_storage/many_backend.rb
|
|
82
|
+
- lib/mobility_active_storage/model.rb
|
|
83
|
+
- lib/mobility_active_storage/version.rb
|
|
84
|
+
- sig/mobility_active_storage.rbs
|
|
85
|
+
homepage: https://github.com/barnaclebarnes/mobility_active_storage
|
|
86
|
+
licenses:
|
|
87
|
+
- MIT
|
|
88
|
+
metadata:
|
|
89
|
+
source_code_uri: https://github.com/barnaclebarnes/mobility_active_storage
|
|
90
|
+
changelog_uri: https://github.com/barnaclebarnes/mobility_active_storage/blob/main/CHANGELOG.md
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
rdoc_options: []
|
|
93
|
+
require_paths:
|
|
94
|
+
- lib
|
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: 3.2.0
|
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '0'
|
|
105
|
+
requirements: []
|
|
106
|
+
rubygems_version: 4.0.17
|
|
107
|
+
specification_version: 4
|
|
108
|
+
summary: Translated Active Storage attachments for Mobility.
|
|
109
|
+
test_files: []
|