paper_trail-human 0.3.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 +52 -0
- data/LICENSE.txt +21 -0
- data/README.md +435 -0
- data/config/locales/en.yml +6 -0
- data/config/locales/pt-BR.yml +6 -0
- data/lib/generators/paper_trail/human/install_generator.rb +17 -0
- data/lib/generators/paper_trail/human/templates/initializer.rb +23 -0
- data/lib/paper_trail/human/adapters/formatters/html.rb +44 -0
- data/lib/paper_trail/human/adapters/formatters/markdown.rb +32 -0
- data/lib/paper_trail/human/adapters/formatters/text.rb +33 -0
- data/lib/paper_trail/human/adapters/resolvers/boolean.rb +22 -0
- data/lib/paper_trail/human/adapters/resolvers/custom.rb +21 -0
- data/lib/paper_trail/human/adapters/resolvers/date.rb +36 -0
- data/lib/paper_trail/human/adapters/resolvers/enum.rb +57 -0
- data/lib/paper_trail/human/adapters/resolvers/number.rb +62 -0
- data/lib/paper_trail/human/adapters/resolvers/relation.rb +41 -0
- data/lib/paper_trail/human/adapters/resolvers/text.rb +29 -0
- data/lib/paper_trail/human/configuration.rb +88 -0
- data/lib/paper_trail/human/core/batch_presenter.rb +133 -0
- data/lib/paper_trail/human/core/change_extractor.rb +79 -0
- data/lib/paper_trail/human/core/event_translator.rb +25 -0
- data/lib/paper_trail/human/core/field_formatter.rb +92 -0
- data/lib/paper_trail/human/core/presenter.rb +76 -0
- data/lib/paper_trail/human/core/timeline.rb +30 -0
- data/lib/paper_trail/human/ports/resolver.rb +13 -0
- data/lib/paper_trail/human/railtie.rb +26 -0
- data/lib/paper_trail/human/version.rb +7 -0
- data/lib/paper_trail/human.rb +74 -0
- data/lib/paper_trail-human.rb +5 -0
- metadata +100 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a4ca501d7c667357906931d0e4460948d92dcd0b8595f1162b0a25d980650c72
|
|
4
|
+
data.tar.gz: 87e69c7b0461fcf423fece4f6f8f6471214861f0fd2300ad484795a3eacc19c6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d16233ba896c6f145177f3852b763aa7f8390cd12b68ae2077e2d9b7287e7cbb93d0944f940fafc77241161a5e5a0db764d160b75fde29338cc6e14143e14165
|
|
7
|
+
data.tar.gz: 342d5cdf3e9742e00bc58dab8ccfaca91b3a47e990d4dcfde4bc4b184c54e1a7f42af4830548f74bc444b2fd5ffcc8213759255950bc205049522595d9b4cdce
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.3.0] - 2026-05-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `:date` resolver with configurable `strftime` format
|
|
7
|
+
- `:number` resolver with currency, percentage, and custom formatting
|
|
8
|
+
- `item_name` resolver for human-readable record identifiers
|
|
9
|
+
- Output formats: `as: :text`, `as: :markdown`, `as: :html` (XSS-safe)
|
|
10
|
+
- `PaperTrail::Human.timeline` for grouping versions by day/week/month/year
|
|
11
|
+
- `after_format` hook for post-processing results
|
|
12
|
+
- CONTRIBUTING.md with architecture guide
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Minimum Ruby version raised to 3.1 (dropped 2.7, 3.0)
|
|
16
|
+
- Minimum Rails version raised to 6.1 (dropped 5.2, 6.0)
|
|
17
|
+
- Minimum PaperTrail version raised to 12.0
|
|
18
|
+
- CI matrix: Ruby 3.1–3.4 × Rails 6.1–8.0 × PaperTrail 12–15
|
|
19
|
+
- README rewritten as full English documentation
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
- Support for Ruby < 3.1, Rails < 6.1, PaperTrail < 12
|
|
23
|
+
|
|
24
|
+
## [0.2.0] - 2026-05-30
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- I18n integration for field names via `activerecord.attributes`
|
|
28
|
+
- I18n event label translation with locale files (en, pt-BR)
|
|
29
|
+
- Rails native enum support (`from_model:` option)
|
|
30
|
+
- `:text` resolver for long text truncation with diff stats
|
|
31
|
+
- `only:` and `except:` field filters
|
|
32
|
+
- Batch loading of relations (N+1 prevention)
|
|
33
|
+
- Event-specific fields (create omits previous_value, destroy omits value)
|
|
34
|
+
- Warning when `object_changes` column is missing
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- `Psych::DisallowedClass` with YAML serializer (added ActiveSupport::TimeWithZone)
|
|
38
|
+
- Field names now remove `_id` suffix automatically
|
|
39
|
+
|
|
40
|
+
## [0.1.0] - 2026-05-29
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- Initial release
|
|
44
|
+
- Core presenter with hexagonal architecture
|
|
45
|
+
- Resolvers: relation, enum, boolean, custom
|
|
46
|
+
- Configuration DSL with per-model field registration
|
|
47
|
+
- Thread-safe configuration
|
|
48
|
+
- Support for JSON, YAML, and jsonb object_changes
|
|
49
|
+
- Whodunnit resolver callback
|
|
50
|
+
- Configurable ignored fields
|
|
51
|
+
- RuboCop configuration (rubocop-rspec, rubocop-performance)
|
|
52
|
+
- CI with GitHub Actions matrix (Ruby 3.0-3.3 × Rails 6.1-8.0)
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gabriel
|
|
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,435 @@
|
|
|
1
|
+
# paper_trail-human
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/paper_trail-human)
|
|
4
|
+
[](https://github.com/gabrielrumiranda/paper_trail-human/actions)
|
|
5
|
+
|
|
6
|
+
Transforms `PaperTrail::Version` records into structured, human-readable hashes ready for UI display — audit logs, timelines, activity feeds.
|
|
7
|
+
|
|
8
|
+
Resolves foreign keys to names, translates enums and constants, formats dates and numbers, and accepts custom transformations via lambda.
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
- [1. Introduction](#1-introduction)
|
|
13
|
+
- [1a. Compatibility](#1a-compatibility)
|
|
14
|
+
- [1b. Installation](#1b-installation)
|
|
15
|
+
- [1c. Quick Start](#1c-quick-start)
|
|
16
|
+
- [2. Configuration](#2-configuration)
|
|
17
|
+
- [2a. Global Options](#2a-global-options)
|
|
18
|
+
- [2b. Per-Model Fields](#2b-per-model-fields)
|
|
19
|
+
- [2c. Item Name](#2c-item-name)
|
|
20
|
+
- [2d. After Format Hook](#2d-after-format-hook)
|
|
21
|
+
- [3. Resolvers](#3-resolvers)
|
|
22
|
+
- [3a. Relation](#3a-relation)
|
|
23
|
+
- [3b. Enum](#3b-enum)
|
|
24
|
+
- [3c. Boolean](#3c-boolean)
|
|
25
|
+
- [3d. Custom](#3d-custom)
|
|
26
|
+
- [3e. Text](#3e-text)
|
|
27
|
+
- [3f. Date](#3f-date)
|
|
28
|
+
- [3g. Number](#3g-number)
|
|
29
|
+
- [4. Formatting](#4-formatting)
|
|
30
|
+
- [4a. Single Version](#4a-single-version)
|
|
31
|
+
- [4b. Collection](#4b-collection)
|
|
32
|
+
- [4c. Filtering Fields](#4c-filtering-fields)
|
|
33
|
+
- [4d. Output Formats](#4d-output-formats)
|
|
34
|
+
- [5. Timeline](#5-timeline)
|
|
35
|
+
- [6. I18n](#6-i18n)
|
|
36
|
+
- [6a. Field Names](#6a-field-names)
|
|
37
|
+
- [6b. Event Labels](#6b-event-labels)
|
|
38
|
+
- [7. Architecture](#7-architecture)
|
|
39
|
+
- [8. Requirements](#8-requirements)
|
|
40
|
+
- [9. Contributing](#9-contributing)
|
|
41
|
+
- [10. License](#10-license)
|
|
42
|
+
|
|
43
|
+
## 1. Introduction
|
|
44
|
+
|
|
45
|
+
### 1a. Compatibility
|
|
46
|
+
|
|
47
|
+
| paper_trail-human | ruby | activerecord | paper_trail |
|
|
48
|
+
|-------------------|---------|--------------|-------------|
|
|
49
|
+
| 0.3.x | >= 3.1 | >= 6.1 | >= 12.0 |
|
|
50
|
+
| 0.2.x | >= 3.0 | >= 6.1 | >= 12.0 |
|
|
51
|
+
| 0.1.x | >= 2.7 | >= 5.2 | >= 9.0 |
|
|
52
|
+
|
|
53
|
+
**CI matrix (0.3.x):**
|
|
54
|
+
|
|
55
|
+
| Rails | PaperTrail | Ruby |
|
|
56
|
+
|-------|-----------|-------------------|
|
|
57
|
+
| 6.1 | ~> 12.0 | 3.1, 3.2, 3.3, 3.4 |
|
|
58
|
+
| 7.0 | ~> 13.0 | 3.1, 3.2, 3.3, 3.4 |
|
|
59
|
+
| 7.1 | ~> 14.0 | 3.1, 3.2, 3.3, 3.4 |
|
|
60
|
+
| 7.2 | ~> 15.0 | 3.1, 3.2, 3.3, 3.4 |
|
|
61
|
+
| 8.0 | ~> 15.0 | 3.2, 3.3, 3.4 |
|
|
62
|
+
|
|
63
|
+
### 1b. Installation
|
|
64
|
+
|
|
65
|
+
Add to your Gemfile:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
gem "paper_trail-human"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then run:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
bundle install
|
|
75
|
+
rails generate paper_trail:human:install
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The generator creates an initializer at `config/initializers/paper_trail_human.rb`.
|
|
79
|
+
|
|
80
|
+
**Important:** This gem reads from the `object_changes` column. If your versions table doesn't have it, add it:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
rails generate paper_trail:install --with-changes
|
|
84
|
+
rails db:migrate
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### 1c. Quick Start
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# config/initializers/paper_trail_human.rb
|
|
91
|
+
PaperTrail::Human.configure do |config|
|
|
92
|
+
config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Anywhere in your app
|
|
96
|
+
PaperTrail::Human.format(version)
|
|
97
|
+
# => {
|
|
98
|
+
# user: "John",
|
|
99
|
+
# event: "update",
|
|
100
|
+
# model: "User",
|
|
101
|
+
# item_id: 1,
|
|
102
|
+
# created_at: 2026-05-29 12:00:00,
|
|
103
|
+
# fields: [
|
|
104
|
+
# { field: "Name", previous_value: "John", value: "John Smith" },
|
|
105
|
+
# { field: "Company", previous_value: "Acme", value: "Globex" }
|
|
106
|
+
# ]
|
|
107
|
+
# }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 2. Configuration
|
|
111
|
+
|
|
112
|
+
### 2a. Global Options
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
PaperTrail::Human.configure do |config|
|
|
116
|
+
# Resolve whodunnit IDs to names (default: nil, returns raw ID)
|
|
117
|
+
config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
|
|
118
|
+
|
|
119
|
+
# Fields to exclude from output (default: %w[id created_at updated_at])
|
|
120
|
+
config.ignored_fields = %w[id created_at updated_at]
|
|
121
|
+
|
|
122
|
+
# Custom field name resolver (default: nil, uses I18n then humanize)
|
|
123
|
+
config.field_name_resolver = ->(field, model) { ... }
|
|
124
|
+
|
|
125
|
+
# Translate event names via I18n (default: false)
|
|
126
|
+
config.translate_events = true
|
|
127
|
+
|
|
128
|
+
# Post-processing hook (default: nil)
|
|
129
|
+
config.after_format = ->(result, version) { result }
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 2b. Per-Model Fields
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
PaperTrail::Human.configure do |config|
|
|
137
|
+
config.register "User" do |m|
|
|
138
|
+
m.field :role, :enum, class_name: "UserRole", method: :label
|
|
139
|
+
m.field :company_id, :relation, class_name: "Company", attribute: :name
|
|
140
|
+
m.field :active, :boolean, true_label: "Active", false_label: "Inactive"
|
|
141
|
+
m.field :bio, :text, max_length: 100, show_diff_stats: true
|
|
142
|
+
m.field :due_date, :date, format: "%d/%m/%Y"
|
|
143
|
+
m.field :salary, :number, format: :currency, unit: "R$"
|
|
144
|
+
m.field :score, :custom, resolve: ->(v) { "#{v} points" }
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 2c. Item Name
|
|
150
|
+
|
|
151
|
+
Adds a human-readable identifier for the record to the output:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
config.register "User" do |m|
|
|
155
|
+
m.item_name :name
|
|
156
|
+
# or with a lambda:
|
|
157
|
+
m.item_name ->(version) { "User ##{version.item_id}" }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
PaperTrail::Human.format(version)[:item_name]
|
|
161
|
+
# => "João Silva"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The `item_name` key is only present when the record exists and the attribute is configured.
|
|
165
|
+
|
|
166
|
+
### 2d. After Format Hook
|
|
167
|
+
|
|
168
|
+
Post-process every formatted result:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
config.after_format = ->(result, version) {
|
|
172
|
+
result[:record_url] = "/#{result[:model].tableize}/#{result[:item_id]}"
|
|
173
|
+
result
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The lambda receives the formatted hash and the original `PaperTrail::Version`, and must return the hash.
|
|
178
|
+
|
|
179
|
+
## 3. Resolvers
|
|
180
|
+
|
|
181
|
+
### 3a. Relation
|
|
182
|
+
|
|
183
|
+
Resolves a foreign key to an attribute of the associated model.
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
m.field :company_id, :relation, class_name: "Company", attribute: :name
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
| Option | Description | Default |
|
|
190
|
+
|--------|-------------|---------|
|
|
191
|
+
| `class_name:` | The associated model class | required |
|
|
192
|
+
| `attribute:` | Attribute to display | `:name` |
|
|
193
|
+
|
|
194
|
+
In batch mode (`format_collection`), relations are preloaded to prevent N+1 queries.
|
|
195
|
+
|
|
196
|
+
### 3b. Enum
|
|
197
|
+
|
|
198
|
+
Resolves enum values to human labels.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
# With a class that responds to a method
|
|
202
|
+
m.field :role, :enum, class_name: "UserRole", method: :label
|
|
203
|
+
|
|
204
|
+
# With a static mapping
|
|
205
|
+
m.field :status, :enum, mapping: { "active" => "Active", "inactive" => "Inactive" }
|
|
206
|
+
|
|
207
|
+
# With Rails native enum
|
|
208
|
+
m.field :role, :enum, from_model: "User"
|
|
209
|
+
m.field :role, :enum, from_model: "User", labels: { admin: "Administrator" }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
| Option | Description |
|
|
213
|
+
|--------|-------------|
|
|
214
|
+
| `class_name:` + `method:` | Calls `ClassName.method(value)` |
|
|
215
|
+
| `mapping:` | Static hash lookup |
|
|
216
|
+
| `from_model:` | Reads from `Model.defined_enums` |
|
|
217
|
+
| `labels:` | Custom labels for `from_model` |
|
|
218
|
+
|
|
219
|
+
### 3c. Boolean
|
|
220
|
+
|
|
221
|
+
Custom labels for boolean fields:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
m.field :active, :boolean, true_label: "Active", false_label: "Inactive"
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### 3d. Custom
|
|
228
|
+
|
|
229
|
+
Arbitrary transformation via lambda:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
m.field :score, :custom, resolve: ->(value) { "#{value} points" }
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### 3e. Text
|
|
236
|
+
|
|
237
|
+
Truncates long text fields:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
m.field :body, :text, max_length: 100, show_diff_stats: true
|
|
241
|
+
# => "Lorem ipsum dolor sit amet..." (250 chars)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
| Option | Description | Default |
|
|
245
|
+
|--------|-------------|---------|
|
|
246
|
+
| `max_length:` | Maximum characters before truncation | `80` |
|
|
247
|
+
| `show_diff_stats:` | Append total char count | `false` |
|
|
248
|
+
|
|
249
|
+
### 3f. Date
|
|
250
|
+
|
|
251
|
+
Formats date/time values:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
m.field :due_date, :date, format: "%d/%m/%Y"
|
|
255
|
+
# => "30/05/2026"
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
| Option | Description | Default |
|
|
259
|
+
|--------|-------------|---------|
|
|
260
|
+
| `format:` | `strftime` format string | `"%Y-%m-%d"` |
|
|
261
|
+
|
|
262
|
+
Accepts `Date`, `Time`, `DateTime`, and parseable strings.
|
|
263
|
+
|
|
264
|
+
### 3g. Number
|
|
265
|
+
|
|
266
|
+
Formats numeric values:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
m.field :amount, :number, format: :currency, unit: "R$"
|
|
270
|
+
# => "R$ 1,500.99"
|
|
271
|
+
|
|
272
|
+
m.field :rate, :number, format: :percentage
|
|
273
|
+
# => "85.50%"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
| Option | Description | Default |
|
|
277
|
+
|--------|-------------|---------|
|
|
278
|
+
| `format:` | `:default`, `:currency`, `:percentage` | `:default` |
|
|
279
|
+
| `unit:` | Currency symbol (for `:currency`) | `nil` |
|
|
280
|
+
| `precision:` | Decimal places | `2` |
|
|
281
|
+
| `delimiter:` | Thousands separator | `","` |
|
|
282
|
+
| `separator:` | Decimal separator | `"."` |
|
|
283
|
+
|
|
284
|
+
## 4. Formatting
|
|
285
|
+
|
|
286
|
+
### 4a. Single Version
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
PaperTrail::Human.format(version)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Returns a hash with keys: `user`, `event`, `model`, `item_id`, `created_at`, `fields`, and optionally `item_name`.
|
|
293
|
+
|
|
294
|
+
Event-specific behavior:
|
|
295
|
+
- **create**: fields omit `previous_value`
|
|
296
|
+
- **update**: fields include both `previous_value` and `value`
|
|
297
|
+
- **destroy**: fields omit `value`
|
|
298
|
+
|
|
299
|
+
### 4b. Collection
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
PaperTrail::Human.format_collection(user.versions)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Same as `format` but for multiple versions. Relations are batch-loaded to prevent N+1 queries.
|
|
306
|
+
|
|
307
|
+
### 4c. Filtering Fields
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
PaperTrail::Human.format(version, only: [:name, :email])
|
|
311
|
+
PaperTrail::Human.format(version, except: [:password_digest])
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### 4d. Output Formats
|
|
315
|
+
|
|
316
|
+
By default, methods return hashes. Use `as:` for string output:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
PaperTrail::Human.format(version, as: :text)
|
|
320
|
+
# => "Updated User#1 by John at 2026-05-30\n • Name: Old → New"
|
|
321
|
+
|
|
322
|
+
PaperTrail::Human.format(version, as: :markdown)
|
|
323
|
+
# => Markdown with header and table
|
|
324
|
+
|
|
325
|
+
PaperTrail::Human.format(version, as: :html)
|
|
326
|
+
# => HTML div with table (XSS-safe, escapes entities)
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Available formats: `:text`, `:markdown`, `:html`.
|
|
330
|
+
|
|
331
|
+
Works with both `format` and `format_collection`.
|
|
332
|
+
|
|
333
|
+
## 5. Timeline
|
|
334
|
+
|
|
335
|
+
Group versions by time period:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
PaperTrail::Human.timeline(user.versions, group_by: :day)
|
|
339
|
+
# => {
|
|
340
|
+
# "2026-05-28" => [{ user: ..., fields: [...] }, ...],
|
|
341
|
+
# "2026-05-30" => [{ user: ..., fields: [...] }]
|
|
342
|
+
# }
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
| `group_by` | Format | Example |
|
|
346
|
+
|-----------|--------|---------|
|
|
347
|
+
| `:day` | `%Y-%m-%d` | `"2026-05-30"` |
|
|
348
|
+
| `:week` | `%G-W%V` | `"2026-W22"` |
|
|
349
|
+
| `:month` | `%Y-%m` | `"2026-05"` |
|
|
350
|
+
| `:year` | `%Y` | `"2026"` |
|
|
351
|
+
|
|
352
|
+
Supports `only:` and `except:` filters.
|
|
353
|
+
|
|
354
|
+
## 6. I18n
|
|
355
|
+
|
|
356
|
+
### 6a. Field Names
|
|
357
|
+
|
|
358
|
+
Field names are resolved in this order:
|
|
359
|
+
|
|
360
|
+
1. Custom `field_name_resolver` lambda (if configured)
|
|
361
|
+
2. `I18n.t("activerecord.attributes.model_name.field_name")` (if I18n available)
|
|
362
|
+
3. Automatic humanization (removes `_id` suffix, titleizes)
|
|
363
|
+
|
|
364
|
+
Example: `company_id` → looks up `activerecord.attributes.user.company_id` → falls back to `"Company"`.
|
|
365
|
+
|
|
366
|
+
### 6b. Event Labels
|
|
367
|
+
|
|
368
|
+
Enable translated event labels:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
config.translate_events = true
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
The gem includes locale files for `en` and `pt-BR`. Add your own:
|
|
375
|
+
|
|
376
|
+
```yaml
|
|
377
|
+
# config/locales/paper_trail_human.en.yml
|
|
378
|
+
en:
|
|
379
|
+
paper_trail_human:
|
|
380
|
+
events:
|
|
381
|
+
create: "Created"
|
|
382
|
+
update: "Updated"
|
|
383
|
+
destroy: "Destroyed"
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
```yaml
|
|
387
|
+
# config/locales/paper_trail_human.pt-BR.yml
|
|
388
|
+
pt-BR:
|
|
389
|
+
paper_trail_human:
|
|
390
|
+
events:
|
|
391
|
+
create: "Criação"
|
|
392
|
+
update: "Atualização"
|
|
393
|
+
destroy: "Exclusão"
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## 7. Architecture
|
|
397
|
+
|
|
398
|
+
Hexagonal (Ports & Adapters):
|
|
399
|
+
|
|
400
|
+
```
|
|
401
|
+
┌─────────────────────────────────────────────┐
|
|
402
|
+
│ Core │
|
|
403
|
+
│ ChangeExtractor · FieldFormatter │
|
|
404
|
+
│ EventTranslator · Presenter │
|
|
405
|
+
│ BatchPresenter · Timeline │
|
|
406
|
+
├─────────────────────────────────────────────┤
|
|
407
|
+
│ Ports │
|
|
408
|
+
│ Resolver (interface) │
|
|
409
|
+
├─────────────────────────────────────────────┤
|
|
410
|
+
│ Adapters │
|
|
411
|
+
│ Resolvers: Relation, Enum, Boolean, │
|
|
412
|
+
│ Custom, Text, Date, Number │
|
|
413
|
+
│ Formatters: Text, Markdown, Html │
|
|
414
|
+
└─────────────────────────────────────────────┘
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
- **Core** — pure formatting logic, no external dependencies
|
|
418
|
+
- **Ports** — `Resolver` interface that every adapter implements
|
|
419
|
+
- **Adapters** — concrete implementations for resolving values and formatting output
|
|
420
|
+
|
|
421
|
+
The gem has zero dependencies beyond `activerecord` and `paper_trail`. The Railtie is optional — it works in non-Rails apps (Sinatra, Hanami, etc).
|
|
422
|
+
|
|
423
|
+
## 8. Requirements
|
|
424
|
+
|
|
425
|
+
- Ruby >= 3.1
|
|
426
|
+
- Rails >= 6.1 (or standalone ActiveRecord)
|
|
427
|
+
- PaperTrail >= 12.0
|
|
428
|
+
|
|
429
|
+
## 9. Contributing
|
|
430
|
+
|
|
431
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on setting up the development environment, running tests, and submitting pull requests.
|
|
432
|
+
|
|
433
|
+
## 10. License
|
|
434
|
+
|
|
435
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/generators'
|
|
4
|
+
|
|
5
|
+
module PaperTrail
|
|
6
|
+
module Human
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
|
9
|
+
|
|
10
|
+
desc 'Creates a PaperTrail::Human initializer'
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template 'initializer.rb', 'config/initializers/paper_trail_human.rb'
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
PaperTrail::Human.configure do |config|
|
|
4
|
+
# Resolver quem fez a alteração (recebe whodunnit ID, retorna nome)
|
|
5
|
+
# config.whodunnit_resolver = ->(id) { User.find_by(id: id)&.name }
|
|
6
|
+
|
|
7
|
+
# Campos ignorados globalmente (default: id, created_at, updated_at)
|
|
8
|
+
# config.ignored_fields = %w[id created_at updated_at]
|
|
9
|
+
|
|
10
|
+
# Resolver customizado para nomes de campos (usa I18n/human_attribute_name)
|
|
11
|
+
# config.field_name_resolver = ->(field_name, item_type) {
|
|
12
|
+
# item_type.constantize.human_attribute_name(field_name)
|
|
13
|
+
# }
|
|
14
|
+
|
|
15
|
+
# Configuração por model:
|
|
16
|
+
#
|
|
17
|
+
# config.register 'User' do |m|
|
|
18
|
+
# m.field :role, :enum, class_name: 'UserRole', method: :label
|
|
19
|
+
# m.field :company_id, :relation, class_name: 'Company', attribute: :name
|
|
20
|
+
# m.field :active, :boolean, true_label: 'Ativo', false_label: 'Inativo'
|
|
21
|
+
# m.field :score, :custom, resolve: ->(v) { "#{v} pontos" }
|
|
22
|
+
# end
|
|
23
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Formatters
|
|
7
|
+
class Html
|
|
8
|
+
def call(result)
|
|
9
|
+
[
|
|
10
|
+
%(<div class="paper-trail-version">),
|
|
11
|
+
" <p>#{header(result)}</p>",
|
|
12
|
+
' <table>',
|
|
13
|
+
' <thead><tr><th>Field</th><th>Previous</th><th>Current</th></tr></thead>',
|
|
14
|
+
' <tbody>',
|
|
15
|
+
*result[:fields].map { |f| table_row(f) },
|
|
16
|
+
' </tbody>',
|
|
17
|
+
' </table>',
|
|
18
|
+
'</div>'
|
|
19
|
+
].join("\n")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def header(result)
|
|
25
|
+
event = escape(result[:event])
|
|
26
|
+
model = escape(result[:model])
|
|
27
|
+
user = escape(result[:user].to_s)
|
|
28
|
+
"<strong>#{event}</strong> #{model}##{result[:item_id]} by #{user}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def table_row(field)
|
|
32
|
+
prev = escape(field.fetch(:previous_value, '—').to_s)
|
|
33
|
+
curr = escape(field.fetch(:value, '—').to_s)
|
|
34
|
+
" <tr><td>#{escape(field[:field])}</td><td>#{prev}</td><td>#{curr}</td></tr>"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def escape(str)
|
|
38
|
+
str.gsub('&', '&').gsub('<', '<').gsub('>', '>').gsub('"', '"').gsub("'", ''')
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Formatters
|
|
7
|
+
class Markdown
|
|
8
|
+
def call(result)
|
|
9
|
+
lines = [header(result), '']
|
|
10
|
+
lines << '| Field | Previous | Current |'
|
|
11
|
+
lines << '|-------|----------|---------|'
|
|
12
|
+
result[:fields].each { |f| lines << table_row(f) }
|
|
13
|
+
lines.join("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def header(result)
|
|
19
|
+
"**#{result[:event]}** `#{result[:model]}##{result[:item_id]}` " \
|
|
20
|
+
"by #{result[:user]} at #{result[:created_at]}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def table_row(field)
|
|
24
|
+
prev = field.fetch(:previous_value, '—')
|
|
25
|
+
curr = field.fetch(:value, '—')
|
|
26
|
+
"| #{field[:field]} | #{prev} | #{curr} |"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module Human
|
|
5
|
+
module Adapters
|
|
6
|
+
module Formatters
|
|
7
|
+
class Text
|
|
8
|
+
def call(result)
|
|
9
|
+
lines = [header(result)]
|
|
10
|
+
result[:fields].each { |f| lines << field_line(f) }
|
|
11
|
+
lines.join("\n")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def header(result)
|
|
17
|
+
"#{result[:event]} #{result[:model]}##{result[:item_id]} by #{result[:user]} at #{result[:created_at]}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def field_line(field)
|
|
21
|
+
if field.key?(:previous_value) && field.key?(:value)
|
|
22
|
+
" • #{field[:field]}: #{field[:previous_value]} → #{field[:value]}"
|
|
23
|
+
elsif field.key?(:value)
|
|
24
|
+
" • #{field[:field]}: #{field[:value]}"
|
|
25
|
+
else
|
|
26
|
+
" • #{field[:field]}: #{field[:previous_value]} (removed)"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|