activeadmin_annotations 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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/CODE_OF_CONDUCT.md +31 -0
  4. data/CONTRIBUTING.md +29 -0
  5. data/GOVERNANCE.md +29 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +321 -0
  8. data/SECURITY.md +27 -0
  9. data/activeadmin_annotations.gemspec +68 -0
  10. data/admin/annotation_reviews.rb +102 -0
  11. data/admin/annotations.rb +146 -0
  12. data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +483 -0
  13. data/app/assets/controllers/activeadmin_annotations/clipboard_controller.js +73 -0
  14. data/app/assets/stylesheets/activeadmin_annotations.css +362 -0
  15. data/app/helpers/active_admin/annotations/panel_helper.rb +95 -0
  16. data/app/models/active_admin/annotations/annotation.rb +37 -0
  17. data/app/models/active_admin/annotations/application_record.rb +13 -0
  18. data/app/models/active_admin/annotations/review.rb +54 -0
  19. data/app/services/active_admin/annotations/review_service.rb +133 -0
  20. data/app/views/active_admin/annotations/_copy_details_action.html.erb +21 -0
  21. data/app/views/active_admin/annotations/_panel.html.erb +98 -0
  22. data/app/views/active_admin/annotations/_read_only_content.html.erb +3 -0
  23. data/config/polyrun_coverage.yml +8 -0
  24. data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +41 -0
  25. data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +9 -0
  26. data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +10 -0
  27. data/lib/activeadmin/annotations/annotatable.rb +15 -0
  28. data/lib/activeadmin/annotations/categories.rb +13 -0
  29. data/lib/activeadmin/annotations/content_revision.rb +101 -0
  30. data/lib/activeadmin/annotations/context.rb +11 -0
  31. data/lib/activeadmin/annotations/copy_text.rb +98 -0
  32. data/lib/activeadmin/annotations/engine.rb +49 -0
  33. data/lib/activeadmin/annotations/exporter.rb +99 -0
  34. data/lib/activeadmin/annotations/review_access.rb +29 -0
  35. data/lib/activeadmin/annotations/review_panel.rb +145 -0
  36. data/lib/activeadmin/annotations/version.rb +7 -0
  37. data/lib/activeadmin/annotations.rb +37 -0
  38. data/lib/activeadmin_annotations.rb +13 -0
  39. data/sig/activeadmin/annotations.rbs +5 -0
  40. metadata +393 -0
@@ -0,0 +1,362 @@
1
+ .aa-annotations-panel {
2
+ margin-top: 1rem;
3
+ }
4
+
5
+ .aa-annotations-stale-banner {
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: space-between;
9
+ gap: 1rem;
10
+ margin-bottom: 1rem;
11
+ padding: 0.75rem 1rem;
12
+ border: 1px solid #f59e0b;
13
+ border-radius: 0.5rem;
14
+ background: #fffbeb;
15
+ }
16
+
17
+ .dark .aa-annotations-stale-banner {
18
+ border-color: #b45309;
19
+ background: #451a03;
20
+ }
21
+
22
+ .aa-annotations-stale-banner p {
23
+ margin: 0;
24
+ }
25
+
26
+ .aa-annotations-version {
27
+ margin: 0 0 0.75rem;
28
+ color: #6b7280;
29
+ font-size: 0.875rem;
30
+ }
31
+
32
+ .dark .aa-annotations-version {
33
+ color: #9ca3af;
34
+ }
35
+
36
+ .aa-annotations-layout {
37
+ align-items: start;
38
+ display: grid;
39
+ gap: 1.5rem;
40
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
41
+ }
42
+
43
+ .aa-annotations-sidebar {
44
+ background: #ffffff;
45
+ border: 1px solid #e5e7eb;
46
+ border-radius: 0.5rem;
47
+ padding: 1rem;
48
+ position: sticky;
49
+ top: 5rem;
50
+ }
51
+
52
+ .dark .aa-annotations-sidebar {
53
+ background: rgb(3 7 18 / 0.6);
54
+ border-color: rgb(255 255 255 / 0.1);
55
+ }
56
+
57
+ .aa-annotations-sidebar h4,
58
+ .aa-annotations-composer h4 {
59
+ color: #111827;
60
+ font-size: 0.95rem;
61
+ font-weight: 600;
62
+ margin: 0 0 0.75rem;
63
+ }
64
+
65
+ .dark .aa-annotations-sidebar h4,
66
+ .dark .aa-annotations-composer h4 {
67
+ color: #f9fafb;
68
+ }
69
+
70
+ .aa-annotations-hint {
71
+ color: #6b7280;
72
+ font-size: 0.875rem;
73
+ line-height: 1.4;
74
+ margin: 0 0 1rem;
75
+ }
76
+
77
+ .dark .aa-annotations-hint {
78
+ color: #9ca3af;
79
+ }
80
+
81
+ .aa-annotations-annotation-item {
82
+ border-top: 1px solid #e5e7eb;
83
+ padding-top: 0.75rem;
84
+ }
85
+
86
+ .aa-annotations-list .aa-annotations-annotation-item:first-child {
87
+ border-top: none;
88
+ padding-top: 0;
89
+ }
90
+
91
+ .dark .aa-annotations-annotation-item {
92
+ border-top-color: rgb(255 255 255 / 0.1);
93
+ }
94
+
95
+ .aa-annotations-annotation-item blockquote {
96
+ border-left: 3px solid #2563eb;
97
+ color: #374151;
98
+ margin: 0 0 0.5rem;
99
+ padding-left: 0.75rem;
100
+ }
101
+
102
+ .dark .aa-annotations-annotation-item blockquote {
103
+ color: #d1d5db;
104
+ }
105
+
106
+ .aa-annotations-annotation-item p {
107
+ color: #111827;
108
+ font-size: 0.875rem;
109
+ line-height: 1.45;
110
+ margin: 0.25rem 0;
111
+ }
112
+
113
+ .aa-annotations-annotation-actions {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ gap: 0.5rem;
117
+ margin-top: 0.75rem;
118
+ }
119
+
120
+ .aa-annotations-annotation-actions .action-item-button {
121
+ cursor: pointer;
122
+ }
123
+
124
+ .aa-annotations-delete-button {
125
+ color: #b91c1c;
126
+ }
127
+
128
+ .dark .aa-annotations-delete-button {
129
+ color: #f87171;
130
+ }
131
+
132
+ .aa-annotations-empty {
133
+ color: #6b7280;
134
+ font-size: 0.875rem;
135
+ margin: 0;
136
+ }
137
+
138
+ .dark .aa-annotations-empty {
139
+ color: #9ca3af;
140
+ }
141
+
142
+ .aa-annotations-list {
143
+ display: flex;
144
+ flex-direction: column;
145
+ gap: 0.75rem;
146
+ }
147
+
148
+ .dark .aa-annotations-annotation-item p {
149
+ color: #e5e7eb;
150
+ }
151
+
152
+ .aa-annotations-composer-hidden,
153
+ .aa-annotations-hidden {
154
+ display: none;
155
+ }
156
+
157
+ .aa-annotations-composer {
158
+ border-bottom: 1px solid #e5e7eb;
159
+ margin-bottom: 1rem;
160
+ padding-bottom: 1rem;
161
+ }
162
+
163
+ .dark .aa-annotations-composer {
164
+ border-bottom-color: rgb(255 255 255 / 0.1);
165
+ }
166
+
167
+ .aa-annotations-composer label {
168
+ color: #374151;
169
+ display: block;
170
+ font-size: 0.875rem;
171
+ font-weight: 500;
172
+ margin: 0 0 0.35rem;
173
+ }
174
+
175
+ .dark .aa-annotations-composer label {
176
+ color: #d1d5db;
177
+ }
178
+
179
+ .aa-annotations-selected-preview {
180
+ background: #f9fafb;
181
+ border: 1px solid #e5e7eb;
182
+ border-left: 3px solid #2563eb;
183
+ border-radius: 0.375rem;
184
+ color: #374151;
185
+ font-size: 0.875rem;
186
+ line-height: 1.45;
187
+ margin: 0 0 1rem;
188
+ padding: 0.65rem 0.75rem;
189
+ }
190
+
191
+ .dark .aa-annotations-selected-preview {
192
+ background: rgb(255 255 255 / 0.05);
193
+ border-color: rgb(255 255 255 / 0.1);
194
+ color: #e5e7eb;
195
+ }
196
+
197
+ .aa-annotations-composer textarea,
198
+ .aa-annotations-composer select {
199
+ background-color: #f9fafb;
200
+ border: 1px solid #d1d5db;
201
+ border-radius: 0.375rem;
202
+ box-sizing: border-box;
203
+ color: #111827;
204
+ display: block;
205
+ font-size: 0.875rem;
206
+ line-height: 1.45;
207
+ margin: 0 0 1rem;
208
+ padding: 0.55rem 0.75rem;
209
+ width: 100%;
210
+ }
211
+
212
+ .aa-annotations-composer textarea {
213
+ min-height: 6rem;
214
+ resize: vertical;
215
+ }
216
+
217
+ .aa-annotations-composer select {
218
+ appearance: auto;
219
+ }
220
+
221
+ .aa-annotations-composer textarea:focus,
222
+ .aa-annotations-composer select:focus {
223
+ border-color: #3b82f6;
224
+ outline: 2px solid transparent;
225
+ outline-offset: 2px;
226
+ box-shadow: 0 0 0 3px rgb(59 130 246 / 0.25);
227
+ }
228
+
229
+ .dark .aa-annotations-composer textarea,
230
+ .dark .aa-annotations-composer select {
231
+ background-color: rgb(255 255 255 / 0.05);
232
+ border-color: rgb(255 255 255 / 0.1);
233
+ color: #ffffff;
234
+ }
235
+
236
+ .dark .aa-annotations-composer select option {
237
+ background-color: #1f2937;
238
+ color: #ffffff;
239
+ }
240
+
241
+ .aa-annotations-composer-actions {
242
+ display: flex;
243
+ flex-wrap: wrap;
244
+ gap: 0.5rem;
245
+ }
246
+
247
+ .aa-annotations-composer-actions .action-item-button {
248
+ cursor: pointer;
249
+ }
250
+
251
+ .aa-annotations-save-button {
252
+ background-color: #2563eb;
253
+ border-color: #2563eb;
254
+ color: #ffffff;
255
+ }
256
+
257
+ .aa-annotations-save-button:hover {
258
+ background-color: #1d4ed8;
259
+ border-color: #1d4ed8;
260
+ color: #ffffff;
261
+ }
262
+
263
+ .dark .aa-annotations-save-button {
264
+ background-color: #2563eb;
265
+ border-color: #2563eb;
266
+ color: #ffffff;
267
+ }
268
+
269
+ .dark .aa-annotations-save-button:hover {
270
+ background-color: #1d4ed8;
271
+ border-color: #1d4ed8;
272
+ color: #ffffff;
273
+ }
274
+
275
+ .aa-annotations-annotatable {
276
+ background: #ffffff;
277
+ border: 1px solid #e5e7eb;
278
+ border-radius: 0.5rem;
279
+ padding: 1rem;
280
+ user-select: text;
281
+ }
282
+
283
+ .dark .aa-annotations-annotatable {
284
+ background: rgb(255 255 255 / 0.03);
285
+ border-color: rgb(255 255 255 / 0.1);
286
+ }
287
+
288
+ ::highlight(aa-annotations-saved) {
289
+ background-color: color-mix(in srgb, #2563eb 30%, #fef08a);
290
+ color: inherit;
291
+ }
292
+
293
+ ::highlight(aa-annotations-pending) {
294
+ background-color: color-mix(in srgb, #2563eb 40%, #fef08a);
295
+ color: inherit;
296
+ text-decoration: underline;
297
+ text-decoration-color: #2563eb;
298
+ text-underline-offset: 2px;
299
+ }
300
+
301
+ .aa-annotations-context {
302
+ margin-top: 1rem;
303
+ }
304
+
305
+ .aa-annotations-context-panel {
306
+ border: 1px solid #e5e7eb;
307
+ border-radius: 0.375rem;
308
+ margin-bottom: 0.75rem;
309
+ padding: 0.5rem 0.75rem;
310
+ }
311
+
312
+ .dark .aa-annotations-context-panel {
313
+ border-color: rgb(255 255 255 / 0.1);
314
+ }
315
+
316
+ .aa-annotations-context-panel summary {
317
+ color: #111827;
318
+ cursor: pointer;
319
+ font-size: 0.875rem;
320
+ font-weight: 600;
321
+ }
322
+
323
+ .dark .aa-annotations-context-panel summary {
324
+ color: #f9fafb;
325
+ }
326
+
327
+ .aa-annotations-context-panel pre {
328
+ background: #f9fafb;
329
+ border-radius: 0.375rem;
330
+ color: #374151;
331
+ font-size: 0.75rem;
332
+ margin-top: 0.5rem;
333
+ max-height: 24rem;
334
+ overflow: auto;
335
+ padding: 0.75rem;
336
+ white-space: pre-wrap;
337
+ }
338
+
339
+ .dark .aa-annotations-context-panel pre {
340
+ background: rgb(255 255 255 / 0.05);
341
+ color: #d1d5db;
342
+ }
343
+
344
+ .aa-annotations-error {
345
+ color: #b91c1c;
346
+ font-size: 0.875rem;
347
+ margin: 0.75rem 0 0;
348
+ }
349
+
350
+ .dark .aa-annotations-error {
351
+ color: #f87171;
352
+ }
353
+
354
+ @media (max-width: 960px) {
355
+ .aa-annotations-layout {
356
+ grid-template-columns: 1fr;
357
+ }
358
+
359
+ .aa-annotations-sidebar {
360
+ position: static;
361
+ }
362
+ }
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ module PanelHelper
5
+ def activeadmin_annotations_panel(
6
+ subject:,
7
+ field:,
8
+ context:,
9
+ context_digest: nil,
10
+ context_panels: [],
11
+ categories: nil,
12
+ category_label: nil,
13
+ content: nil,
14
+ content_revision_enabled: nil,
15
+ content_revision_version: nil,
16
+ latest_content_revision_version: nil,
17
+ content_stale: false,
18
+ advance_review_url: nil,
19
+ annotations: nil,
20
+ review: nil,
21
+ &content_block
22
+ )
23
+ rendered_content = content.presence || capture(&content_block)
24
+ reviewer = current_activeadmin_annotations_reviewer
25
+ unless reviewer
26
+ return render partial: "active_admin/annotations/read_only_content",
27
+ locals: {content: rendered_content}
28
+ end
29
+
30
+ revision_enabled = content_revision_enabled.nil? ? ContentRevision.enabled_for?(subject) : content_revision_enabled
31
+ review ||= ReviewService.find_or_sync_review!(
32
+ subject: subject,
33
+ reviewer: reviewer,
34
+ context: context,
35
+ context_digest: context_digest,
36
+ content_revision_version: content_revision_version || 0,
37
+ latest_content_revision_version: latest_content_revision_version || content_revision_version || 0,
38
+ content_revision_enabled: revision_enabled
39
+ )
40
+ annotations ||= annotations_for(review:, field:, revision_enabled:)
41
+ stale_message = stale_message_for(
42
+ review: review,
43
+ revision_enabled: revision_enabled,
44
+ pinned_version: review.content_revision_version,
45
+ latest_version: latest_content_revision_version || review.content_revision_version
46
+ )
47
+
48
+ render partial: "active_admin/annotations/panel",
49
+ locals: {
50
+ subject: subject,
51
+ field: field.to_s,
52
+ review: review,
53
+ annotations: annotations,
54
+ context_panels: context_panels,
55
+ categories: categories || ActiveAdmin::Annotations.categories,
56
+ category_label: category_label || ActiveAdmin::Annotations.category_label,
57
+ content: rendered_content,
58
+ content_revision_enabled: revision_enabled,
59
+ content_revision_version: review.content_revision_version,
60
+ latest_content_revision_version: latest_content_revision_version || review.content_revision_version,
61
+ content_revision_label: ContentRevision.version_label(review.content_revision_version),
62
+ content_stale: content_stale || review.context_stale?,
63
+ stale_message: stale_message,
64
+ advance_review_url: advance_review_url
65
+ }
66
+ end
67
+
68
+ private
69
+
70
+ def annotations_for(review:, field:, revision_enabled:)
71
+ scope = review.annotations.where(field_name: field.to_s).order(:created_at)
72
+ return scope unless revision_enabled
73
+
74
+ scope.where(content_revision_version: review.content_revision_version)
75
+ end
76
+
77
+ def stale_message_for(review:, revision_enabled:, pinned_version:, latest_version:)
78
+ return unless review.context_stale?
79
+
80
+ if revision_enabled
81
+ ContentRevision.stale_review_message(
82
+ review: review,
83
+ pinned_version: pinned_version,
84
+ latest_version: latest_version
85
+ )
86
+ else
87
+ ContentRevision.stale_context_message(review: review)
88
+ end
89
+ end
90
+
91
+ def current_activeadmin_annotations_reviewer
92
+ respond_to?(:current_user) ? current_user : nil
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveAdmin::Annotations::Annotation < ActiveAdmin::Annotations::ApplicationRecord
4
+ self.table_name = "annotation_spans"
5
+
6
+ belongs_to :annotation_review,
7
+ class_name: "ActiveAdmin::Annotations::Review",
8
+ inverse_of: :annotations
9
+
10
+ validates :field_name, :selected_text, :comment, presence: true
11
+ validates :start_offset, :end_offset, numericality: {greater_than_or_equal_to: 0}
12
+ validate :end_offset_after_start_offset
13
+ validate :category_must_be_allowed
14
+
15
+ def self.ransackable_attributes(_auth_object = nil)
16
+ %w[annotation_review_id field_name category comment created_at]
17
+ end
18
+
19
+ def self.ransackable_associations(_auth_object = nil)
20
+ %w[annotation_review]
21
+ end
22
+
23
+ private
24
+
25
+ def end_offset_after_start_offset
26
+ return if start_offset.blank? || end_offset.blank?
27
+ return if end_offset >= start_offset
28
+
29
+ errors.add(:end_offset, "must be greater than or equal to start offset")
30
+ end
31
+
32
+ def category_must_be_allowed
33
+ return if ActiveAdmin::Annotations::Categories.allowed?(category)
34
+
35
+ errors.add(:category, "is invalid")
36
+ end
37
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveAdmin::Annotations::ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+
6
+ def self.ransackable_associations(_auth_object = nil)
7
+ reflect_on_all_associations.map { |association| association.name.to_s }
8
+ end
9
+
10
+ def self.ransackable_attributes(_auth_object = nil)
11
+ column_names
12
+ end
13
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveAdmin::Annotations::Review < ActiveAdmin::Annotations::ApplicationRecord
4
+ self.table_name = "annotation_reviews"
5
+
6
+ REVIEW_STATUSES = %w[pending reviewed escalated].freeze
7
+
8
+ belongs_to :subject, polymorphic: true
9
+ belongs_to :reviewer, class_name: ActiveAdmin::Annotations.reviewer_class_name
10
+
11
+ has_many :annotations,
12
+ class_name: "ActiveAdmin::Annotations::Annotation",
13
+ foreign_key: :annotation_review_id,
14
+ inverse_of: :annotation_review,
15
+ dependent: :destroy
16
+
17
+ validates :review_status, inclusion: {in: REVIEW_STATUSES}
18
+ validates :reviewer_id, uniqueness: {scope: %i[subject_type subject_id]}
19
+ validates :context_json, presence: true
20
+
21
+ scope :pending, -> { where(review_status: "pending") }
22
+ scope :reviewed, -> { where(review_status: "reviewed") }
23
+ scope :escalated, -> { where(review_status: "escalated") }
24
+ scope :needs_follow_up, lambda {
25
+ if connection.adapter_name.match?(/postgres/i)
26
+ where("metadata_json @> ?", {needs_follow_up: true}.to_json)
27
+ else
28
+ where("json_extract(metadata_json, '$.needs_follow_up') = ?", true)
29
+ end
30
+ }
31
+
32
+ def needs_follow_up?
33
+ ActiveModel::Type::Boolean.new.cast(metadata_json.to_h["needs_follow_up"])
34
+ end
35
+
36
+ def context_stale?
37
+ ActiveModel::Type::Boolean.new.cast(metadata_json.to_h["context_stale"])
38
+ end
39
+
40
+ def self.ransackable_attributes(_auth_object = nil)
41
+ %w[
42
+ subject_type
43
+ subject_id
44
+ reviewer_id
45
+ review_status
46
+ context_digest
47
+ created_at
48
+ ]
49
+ end
50
+
51
+ def self.ransackable_associations(_auth_object = nil)
52
+ %w[reviewer subject annotations]
53
+ end
54
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ class ReviewService
5
+ class << self
6
+ def find_or_sync_review!(
7
+ subject:,
8
+ reviewer:,
9
+ context:,
10
+ context_digest: nil,
11
+ content_revision_version: 0,
12
+ latest_content_revision_version: content_revision_version,
13
+ content_revision_enabled: ContentRevision.enabled_for?(subject)
14
+ )
15
+ 2.times do |attempt|
16
+ return sync_and_save_review!(
17
+ subject: subject,
18
+ reviewer: reviewer,
19
+ context: context,
20
+ context_digest: context_digest,
21
+ content_revision_version: content_revision_version,
22
+ latest_content_revision_version: latest_content_revision_version,
23
+ content_revision_enabled: content_revision_enabled
24
+ )
25
+ rescue ActiveRecord::RecordNotUnique
26
+ raise if attempt == 1
27
+ end
28
+ end
29
+
30
+ def advance_to_latest!(
31
+ subject:,
32
+ reviewer:,
33
+ context:,
34
+ latest_content_revision_version:, context_digest: nil,
35
+ content_revision_enabled: ContentRevision.enabled_for?(subject)
36
+ )
37
+ raise ArgumentError, "content revisions are not enabled for this subject" unless content_revision_enabled
38
+
39
+ review = Review.find_by!(subject: subject, reviewer: reviewer)
40
+ context_hash = context.deep_stringify_keys
41
+ digest = context_digest.presence || Context.digest_for(context_hash)
42
+ latest_version = latest_content_revision_version.to_i
43
+
44
+ review.update!(
45
+ content_revision_version: latest_version,
46
+ context_json: context_hash,
47
+ context_digest: digest,
48
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
49
+ )
50
+ review
51
+ end
52
+
53
+ private
54
+
55
+ def sync_with_content_revision!(review, context_hash:, digest:, pinned_version:, latest_version:)
56
+ if review.new_record?
57
+ review.assign_attributes(
58
+ content_revision_version: pinned_version,
59
+ context_json: context_hash,
60
+ context_digest: digest,
61
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
62
+ )
63
+ elsif latest_version > review.content_revision_version
64
+ review.metadata_json = review.metadata_json.to_h.merge("context_stale" => true)
65
+ elsif review.content_revision_version == pinned_version && review.context_digest != digest
66
+ review.assign_attributes(
67
+ context_json: context_hash,
68
+ context_digest: digest,
69
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
70
+ )
71
+ elsif !review.context_stale?
72
+ review.metadata_json = review.metadata_json.to_h.merge("context_stale" => false)
73
+ end
74
+ end
75
+
76
+ def sync_and_save_review!(
77
+ subject:,
78
+ reviewer:,
79
+ context:,
80
+ context_digest:,
81
+ content_revision_version:,
82
+ latest_content_revision_version:,
83
+ content_revision_enabled:
84
+ )
85
+ context_hash = context.deep_stringify_keys
86
+ digest = context_digest.presence || Context.digest_for(context_hash)
87
+ pinned_version = content_revision_version.to_i
88
+ latest_version = latest_content_revision_version.to_i
89
+
90
+ review = Review.find_or_initialize_by(
91
+ subject: subject,
92
+ reviewer: reviewer
93
+ )
94
+
95
+ if content_revision_enabled
96
+ sync_with_content_revision!(
97
+ review,
98
+ context_hash: context_hash,
99
+ digest: digest,
100
+ pinned_version: pinned_version,
101
+ latest_version: latest_version
102
+ )
103
+ else
104
+ sync_without_content_revision!(review, context_hash: context_hash, digest: digest)
105
+ end
106
+
107
+ return review unless review.changed?
108
+
109
+ review.save!
110
+ review
111
+ end
112
+
113
+ def sync_without_content_revision!(review, context_hash:, digest:)
114
+ if review.new_record?
115
+ review.assign_attributes(
116
+ content_revision_version: 0,
117
+ context_json: context_hash,
118
+ context_digest: digest,
119
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
120
+ )
121
+ elsif review.context_digest != digest
122
+ review.metadata_json = review.metadata_json.to_h.merge("context_stale" => true)
123
+ else
124
+ review.assign_attributes(
125
+ context_json: context_hash,
126
+ context_digest: digest,
127
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
128
+ )
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,21 @@
1
+ <div
2
+ class="aa-annotations-copy-action"
3
+ data-controller="activeadmin-annotations--clipboard"
4
+ data-activeadmin-annotations--clipboard-source-url-value="<%= copy_text_url %>"
5
+ data-activeadmin-annotations--clipboard-success-message-value="Copied annotation details">
6
+ <button
7
+ type="button"
8
+ class="action-item-button"
9
+ data-action="activeadmin-annotations--clipboard#copyFromUrl">
10
+ Copy details
11
+ </button>
12
+ <p
13
+ class="aa-annotations-copy-action__status"
14
+ data-activeadmin-annotations--clipboard-target="status"
15
+ aria-live="polite"></p>
16
+ <textarea
17
+ class="aa-annotations-copy-action__manual-copy"
18
+ data-activeadmin-annotations--clipboard-target="manualCopy"
19
+ hidden
20
+ readonly></textarea>
21
+ </div>