activeadmin_annotations 0.1.1 → 0.1.2

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/CODE_OF_CONDUCT.md +14 -19
  4. data/CONTRIBUTING.md +9 -15
  5. data/GOVERNANCE.md +10 -14
  6. data/README.md +24 -7
  7. data/SECURITY.md +14 -16
  8. data/activeadmin_annotations.gemspec +2 -2
  9. data/admin/annotation_reviews.rb +14 -6
  10. data/admin/annotations.rb +33 -13
  11. data/app/assets/controllers/activeadmin_annotations/annotation_client.js +44 -0
  12. data/app/assets/controllers/activeadmin_annotations/annotation_list.js +29 -0
  13. data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +45 -370
  14. data/app/assets/controllers/activeadmin_annotations/composer.js +179 -0
  15. data/app/assets/controllers/activeadmin_annotations/highlights.js +76 -0
  16. data/app/assets/controllers/activeadmin_annotations/selection.js +73 -0
  17. data/app/assets/javascripts/activeadmin_annotations/annotator_logic.mjs +73 -0
  18. data/app/assets/stylesheets/activeadmin_annotations.css +15 -0
  19. data/app/helpers/active_admin/annotations/panel_helper.rb +15 -8
  20. data/app/models/active_admin/annotations/review.rb +7 -2
  21. data/app/services/active_admin/annotations/review_service.rb +34 -27
  22. data/app/views/active_admin/annotations/_panel.html.erb +15 -2
  23. data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +27 -10
  24. data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +1 -1
  25. data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +1 -1
  26. data/lib/activeadmin/annotations/copy_text.rb +0 -1
  27. data/lib/activeadmin/annotations/engine.rb +16 -1
  28. data/lib/activeadmin/annotations/exporter.rb +1 -1
  29. data/lib/activeadmin/annotations/panel_html.rb +21 -0
  30. data/lib/activeadmin/annotations/review_access.rb +13 -1
  31. data/lib/activeadmin/annotations/review_panel.rb +9 -6
  32. data/lib/activeadmin/annotations/span_create.rb +70 -0
  33. data/lib/activeadmin/annotations/version.rb +1 -1
  34. data/lib/activeadmin_annotations.rb +2 -0
  35. metadata +12 -4
@@ -0,0 +1,76 @@
1
+ import { rangeForOffsets } from "activeadmin_annotations/selection";
2
+ import { highlightsSupported } from "activeadmin_annotations/annotator_logic";
3
+
4
+ export const PENDING_HIGHLIGHT_NAME = "aa-annotations-pending";
5
+ export const SAVED_HIGHLIGHT_NAME = "aa-annotations-saved";
6
+
7
+ const MARK_CLASS_BY_NAME = {
8
+ [PENDING_HIGHLIGHT_NAME]: "aa-annotations-mark aa-annotations-mark--pending",
9
+ [SAVED_HIGHLIGHT_NAME]: "aa-annotations-mark aa-annotations-mark--saved",
10
+ };
11
+
12
+ export { highlightsSupported };
13
+
14
+ export class HighlightLayer {
15
+ constructor(root) {
16
+ this.root = root;
17
+ this.supported = highlightsSupported();
18
+ }
19
+
20
+ set(name, offsetPairs) {
21
+ this.clear(name);
22
+ if (offsetPairs.length === 0) return;
23
+
24
+ if (this.supported) {
25
+ const highlight = new Highlight();
26
+ offsetPairs.forEach(([startOffset, endOffset]) => {
27
+ const range = rangeForOffsets(this.root, startOffset, endOffset);
28
+ if (range) highlight.add(range);
29
+ });
30
+ CSS.highlights.set(name, highlight);
31
+ return;
32
+ }
33
+
34
+ offsetPairs.forEach(([startOffset, endOffset]) => {
35
+ const range = rangeForOffsets(this.root, startOffset, endOffset);
36
+ if (range) this.wrapRange(range, name);
37
+ });
38
+ }
39
+
40
+ clear(name) {
41
+ if (this.supported) {
42
+ CSS.highlights.delete(name);
43
+ return;
44
+ }
45
+
46
+ this.root.querySelectorAll(`mark[data-aa-highlight="${name}"]`).forEach((mark) => {
47
+ const parent = mark.parentNode;
48
+ if (!parent) return;
49
+
50
+ while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
51
+ parent.removeChild(mark);
52
+ parent.normalize();
53
+ });
54
+ }
55
+
56
+ disconnect() {
57
+ this.clear(PENDING_HIGHLIGHT_NAME);
58
+ this.clear(SAVED_HIGHLIGHT_NAME);
59
+ }
60
+
61
+ wrapRange(range, name) {
62
+ if (range.collapsed) return;
63
+
64
+ const mark = document.createElement("mark");
65
+ mark.dataset.aaHighlight = name;
66
+ mark.className = MARK_CLASS_BY_NAME[name] || "aa-annotations-mark";
67
+
68
+ try {
69
+ range.surroundContents(mark);
70
+ } catch {
71
+ const contents = range.extractContents();
72
+ mark.appendChild(contents);
73
+ range.insertNode(mark);
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,73 @@
1
+ export function currentSelection(selection, annotatable) {
2
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
3
+
4
+ const range = selection.getRangeAt(0);
5
+ if (!selectionInsideAnnotatable(range, annotatable)) return null;
6
+
7
+ const selectedText = selection.toString().trim();
8
+ if (!selectedText) return null;
9
+
10
+ const startOffset = offsetWithinAnnotatable(annotatable, range.startContainer, range.startOffset);
11
+ const endOffset = offsetWithinAnnotatable(annotatable, range.endContainer, range.endOffset);
12
+ if (startOffset === null || endOffset === null || endOffset <= startOffset) return null;
13
+
14
+ return { selectedText, startOffset, endOffset };
15
+ }
16
+
17
+ export function rangeForOffsets(annotatable, startOffset, endOffset) {
18
+ const walker = document.createTreeWalker(annotatable, NodeFilter.SHOW_TEXT);
19
+ let position = 0;
20
+ let startNode = null;
21
+ let startPosition = 0;
22
+ let endNode = null;
23
+ let endPosition = 0;
24
+
25
+ while (walker.nextNode()) {
26
+ const node = walker.currentNode;
27
+ const length = node.textContent.length;
28
+ const nextPosition = position + length;
29
+
30
+ if (!startNode && startOffset <= nextPosition) {
31
+ startNode = node;
32
+ startPosition = startOffset - position;
33
+ }
34
+
35
+ if (!endNode && endOffset <= nextPosition) {
36
+ endNode = node;
37
+ endPosition = endOffset - position;
38
+ break;
39
+ }
40
+
41
+ position = nextPosition;
42
+ }
43
+
44
+ if (!startNode || !endNode) return null;
45
+
46
+ const range = document.createRange();
47
+ range.setStart(startNode, startPosition);
48
+ range.setEnd(endNode, endPosition);
49
+ return range;
50
+ }
51
+
52
+ function selectionInsideAnnotatable(range, annotatable) {
53
+ const startElement = elementForNode(range.startContainer);
54
+ const endElement = elementForNode(range.endContainer);
55
+ return annotatable.contains(startElement) && annotatable.contains(endElement);
56
+ }
57
+
58
+ function elementForNode(node) {
59
+ return node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
60
+ }
61
+
62
+ function offsetWithinAnnotatable(annotatable, node, offset) {
63
+ const walker = document.createTreeWalker(annotatable, NodeFilter.SHOW_TEXT);
64
+ let position = 0;
65
+
66
+ while (walker.nextNode()) {
67
+ const current = walker.currentNode;
68
+ if (current === node) return position + offset;
69
+ position += current.textContent.length;
70
+ }
71
+
72
+ return null;
73
+ }
@@ -0,0 +1,73 @@
1
+ export function escapeHtml(value) {
2
+ return value
3
+ .replaceAll("&", "&amp;")
4
+ .replaceAll("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;")
7
+ }
8
+
9
+ export function categoryLabelFor(value, categories) {
10
+ if (!value) return ""
11
+ return categories[value] || value
12
+ }
13
+
14
+ export function createAnnotationPayload(panel, comment) {
15
+ const payload = {
16
+ annotation: {
17
+ field_name: panel.fieldValue,
18
+ selected_text: panel.pendingSelection.selectedText,
19
+ start_offset: panel.pendingSelection.startOffset,
20
+ end_offset: panel.pendingSelection.endOffset,
21
+ comment: comment,
22
+ category: panel.categoryInputTarget.value || null,
23
+ context_paths_json: [],
24
+ },
25
+ }
26
+
27
+ if (panel.reviewIdValue) {
28
+ payload.annotation.annotation_review_id = panel.reviewIdValue
29
+ } else {
30
+ payload.annotation.subject_type = panel.subjectTypeValue
31
+ payload.annotation.subject_id = panel.subjectIdValue
32
+ payload.annotation.context_json = panel.contextValue
33
+ payload.annotation.context_digest = panel.contextDigestValue
34
+ }
35
+
36
+ if (panel.hasContentRevisionVersionValue) {
37
+ payload.annotation.displayed_content_revision_version = panel.contentRevisionVersionValue
38
+ }
39
+
40
+ return payload
41
+ }
42
+
43
+ export function normalizeAnnotation(annotation) {
44
+ return {
45
+ id: annotation.id,
46
+ field_name: annotation.field_name,
47
+ selected_text: annotation.selected_text,
48
+ start_offset: annotation.start_offset,
49
+ end_offset: annotation.end_offset,
50
+ comment: annotation.comment,
51
+ category: annotation.category,
52
+ }
53
+ }
54
+
55
+ export function savedHighlightOffsetPairs(annotations, editingAnnotationId) {
56
+ return annotations.flatMap((annotation) => {
57
+ if (editingAnnotationId && String(annotation.id) === String(editingAnnotationId)) {
58
+ return []
59
+ }
60
+
61
+ const startOffset = Number(annotation.start_offset)
62
+ const endOffset = Number(annotation.end_offset)
63
+ if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset) || endOffset <= startOffset) {
64
+ return []
65
+ }
66
+
67
+ return [[startOffset, endOffset]]
68
+ })
69
+ }
70
+
71
+ export function highlightsSupported() {
72
+ return typeof CSS !== "undefined" && Boolean(CSS.highlights)
73
+ }
@@ -298,6 +298,21 @@
298
298
  text-underline-offset: 2px;
299
299
  }
300
300
 
301
+ .aa-annotations-mark {
302
+ color: inherit;
303
+ }
304
+
305
+ .aa-annotations-mark--saved {
306
+ background-color: color-mix(in srgb, #2563eb 30%, #fef08a);
307
+ }
308
+
309
+ .aa-annotations-mark--pending {
310
+ background-color: color-mix(in srgb, #2563eb 40%, #fef08a);
311
+ text-decoration: underline;
312
+ text-decoration-color: #2563eb;
313
+ text-underline-offset: 2px;
314
+ }
315
+
301
316
  .aa-annotations-context {
302
317
  margin-top: 1rem;
303
318
  }
@@ -18,9 +18,10 @@ module ActiveAdmin::Annotations
18
18
  advance_review_url: nil,
19
19
  annotations: nil,
20
20
  review: nil,
21
+ readonly: false,
21
22
  &content_block
22
23
  )
23
- rendered_content = content.presence || capture(&content_block)
24
+ rendered_content = PanelHtml.prepare(content.presence || capture(&content_block))
24
25
  reviewer = current_activeadmin_annotations_reviewer
25
26
  unless reviewer
26
27
  return render partial: "active_admin/annotations/read_only_content",
@@ -28,7 +29,7 @@ module ActiveAdmin::Annotations
28
29
  end
29
30
 
30
31
  revision_enabled = content_revision_enabled.nil? ? ContentRevision.enabled_for?(subject) : content_revision_enabled
31
- review ||= ReviewService.find_or_sync_review!(
32
+ review ||= ReviewService.preview_review(
32
33
  subject: subject,
33
34
  reviewer: reviewer,
34
35
  context: context,
@@ -37,7 +38,8 @@ module ActiveAdmin::Annotations
37
38
  latest_content_revision_version: latest_content_revision_version || content_revision_version || 0,
38
39
  content_revision_enabled: revision_enabled
39
40
  )
40
- annotations ||= annotations_for(review:, field:, revision_enabled:)
41
+ display_version = content_revision_version.nil? ? review.content_revision_version : content_revision_version.to_i
42
+ annotations ||= annotations_for(review:, field:, revision_enabled:, display_version:)
41
43
  stale_message = stale_message_for(
42
44
  review: review,
43
45
  revision_enabled: revision_enabled,
@@ -51,27 +53,32 @@ module ActiveAdmin::Annotations
51
53
  field: field.to_s,
52
54
  review: review,
53
55
  annotations: annotations,
56
+ context: context,
57
+ context_digest: context_digest || Context.digest_for(context),
54
58
  context_panels: context_panels,
55
59
  categories: categories || ActiveAdmin::Annotations.categories,
56
60
  category_label: category_label || ActiveAdmin::Annotations.category_label,
57
61
  content: rendered_content,
58
62
  content_revision_enabled: revision_enabled,
59
- content_revision_version: review.content_revision_version,
63
+ content_revision_version: display_version,
60
64
  latest_content_revision_version: latest_content_revision_version || review.content_revision_version,
61
- content_revision_label: ContentRevision.version_label(review.content_revision_version),
65
+ content_revision_label: ContentRevision.version_label(display_version),
62
66
  content_stale: content_stale || review.context_stale?,
63
67
  stale_message: stale_message,
64
- advance_review_url: advance_review_url
68
+ advance_review_url: advance_review_url,
69
+ readonly: readonly
65
70
  }
66
71
  end
67
72
 
68
73
  private
69
74
 
70
- def annotations_for(review:, field:, revision_enabled:)
75
+ def annotations_for(review:, field:, revision_enabled:, display_version:)
76
+ return [] unless review&.persisted?
77
+
71
78
  scope = review.annotations.where(field_name: field.to_s).order(:created_at)
72
79
  return scope unless revision_enabled
73
80
 
74
- scope.where(content_revision_version: review.content_revision_version)
81
+ scope.where(content_revision_version: display_version)
75
82
  end
76
83
 
77
84
  def stale_message_for(review:, revision_enabled:, pinned_version:, latest_version:)
@@ -30,13 +30,18 @@ class ActiveAdmin::Annotations::Review < ActiveAdmin::Annotations::ApplicationRe
30
30
  }
31
31
 
32
32
  def needs_follow_up?
33
- ActiveModel::Type::Boolean.new.cast(metadata_json.to_h["needs_follow_up"])
33
+ metadata_flag("needs_follow_up")
34
34
  end
35
35
 
36
36
  def context_stale?
37
- ActiveModel::Type::Boolean.new.cast(metadata_json.to_h["context_stale"])
37
+ metadata_flag("context_stale")
38
38
  end
39
39
 
40
+ def metadata_flag(key)
41
+ ActiveModel::Type::Boolean.new.cast(metadata_json.to_h[key]) == true
42
+ end
43
+ private :metadata_flag
44
+
40
45
  def self.ransackable_attributes(_auth_object = nil)
41
46
  %w[
42
47
  subject_type
@@ -3,6 +3,10 @@
3
3
  module ActiveAdmin::Annotations
4
4
  class ReviewService
5
5
  class << self
6
+ def preview_review(**kwargs)
7
+ assemble_review!(**kwargs, persist: false)
8
+ end
9
+
6
10
  def find_or_sync_review!(
7
11
  subject:,
8
12
  reviewer:,
@@ -52,35 +56,19 @@ module ActiveAdmin::Annotations
52
56
 
53
57
  private
54
58
 
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
59
+ def sync_and_save_review!(**kwargs)
60
+ assemble_review!(**kwargs, persist: true)
74
61
  end
75
62
 
76
- def sync_and_save_review!(
63
+ def assemble_review!(
77
64
  subject:,
78
65
  reviewer:,
79
66
  context:,
80
- context_digest:,
81
- content_revision_version:,
82
- latest_content_revision_version:,
83
- content_revision_enabled:
67
+ persist:,
68
+ context_digest: nil,
69
+ content_revision_version: 0,
70
+ latest_content_revision_version: content_revision_version,
71
+ content_revision_enabled: ContentRevision.enabled_for?(subject)
84
72
  )
85
73
  context_hash = context.deep_stringify_keys
86
74
  digest = context_digest.presence || Context.digest_for(context_hash)
@@ -104,12 +92,31 @@ module ActiveAdmin::Annotations
104
92
  sync_without_content_revision!(review, context_hash: context_hash, digest: digest)
105
93
  end
106
94
 
107
- return review unless review.changed?
108
-
109
- review.save!
95
+ review.save! if persist
110
96
  review
111
97
  end
112
98
 
99
+ def sync_with_content_revision!(review, context_hash:, digest:, pinned_version:, latest_version:)
100
+ if review.new_record?
101
+ review.assign_attributes(
102
+ content_revision_version: pinned_version,
103
+ context_json: context_hash,
104
+ context_digest: digest,
105
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
106
+ )
107
+ elsif latest_version > review.content_revision_version
108
+ review.metadata_json = review.metadata_json.to_h.merge("context_stale" => true)
109
+ elsif review.content_revision_version == pinned_version && review.context_digest != digest
110
+ review.assign_attributes(
111
+ context_json: context_hash,
112
+ context_digest: digest,
113
+ metadata_json: review.metadata_json.to_h.merge("context_stale" => false)
114
+ )
115
+ elsif !review.context_stale?
116
+ review.metadata_json = review.metadata_json.to_h.merge("context_stale" => false)
117
+ end
118
+ end
119
+
113
120
  def sync_without_content_revision!(review, context_hash:, digest:)
114
121
  if review.new_record?
115
122
  review.assign_attributes(
@@ -10,7 +10,10 @@
10
10
  }
11
11
  end %>
12
12
  <% category_options = categories.to_h %>
13
- <% panel_id = "aa-annotation-#{review.id}" %>
13
+ <% readonly = local_assigns.fetch(:readonly, false) %>
14
+ <% panel_id = review.id.present? ? "aa-annotation-#{review.id}" : "aa-annotation-new-#{field}" %>
15
+ <% context_payload = local_assigns.fetch(:context, {}) %>
16
+ <% context_digest_value = local_assigns[:context_digest] %>
14
17
 
15
18
  <%= stylesheet_link_tag "activeadmin_annotations", "data-turbo-track": "reload" %>
16
19
 
@@ -32,6 +35,12 @@
32
35
  data-controller="activeadmin-annotations--annotator"
33
36
  data-activeadmin-annotations--annotator-review-id-value="<%= review.id %>"
34
37
  data-activeadmin-annotations--annotator-field-value="<%= field %>"
38
+ data-activeadmin-annotations--annotator-subject-type-value="<%= subject.class.name %>"
39
+ data-activeadmin-annotations--annotator-subject-id-value="<%= subject.id %>"
40
+ data-activeadmin-annotations--annotator-context-value="<%= ERB::Util.html_escape(context_payload.to_json) %>"
41
+ data-activeadmin-annotations--annotator-context-digest-value="<%= context_digest_value %>"
42
+ data-activeadmin-annotations--annotator-content-revision-version-value="<%= content_revision_version %>"
43
+ data-activeadmin-annotations--annotator-readonly-value="<%= readonly ? "true" : "false" %>"
35
44
  data-activeadmin-annotations--annotator-category-label-value="<%= category_label %>"
36
45
  data-activeadmin-annotations--annotator-categories-value="<%= ERB::Util.html_escape(category_options.to_json) %>"
37
46
  data-activeadmin-annotations--annotator-create-url-value="<%= admin_annotation_spans_path(format: :json) %>"
@@ -60,7 +69,11 @@
60
69
 
61
70
  <aside class="aa-annotations-sidebar" data-activeadmin-annotations--annotator-target="sidebar">
62
71
  <h4>Annotations</h4>
63
- <p class="aa-annotations-hint">Select text in the block, then add a comment.</p>
72
+ <% if readonly %>
73
+ <p class="aa-annotations-hint">These notes belong to another version. Add notes on the version being reviewed.</p>
74
+ <% else %>
75
+ <p class="aa-annotations-hint">Select text in the block, then add a comment.</p>
76
+ <% end %>
64
77
  <% if content_revision_enabled && content_revision_label.present? %>
65
78
  <p class="aa-annotations-version"><%= content_revision_label %></p>
66
79
  <% end %>
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[8.1]
3
+ class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[7.1]
4
4
  def change
5
- create_table :annotation_reviews, id: :uuid do |t|
6
- t.references :subject, polymorphic: true, null: false, type: :uuid, index: true
7
- t.uuid :reviewer_id, null: false
5
+ enable_pgcrypto_for_postgres
6
+
7
+ json_column_type = postgres? ? :jsonb : :json
8
+ uuid_column_type = postgres? ? :uuid : :string
9
+
10
+ create_table :annotation_reviews, id: uuid_column_type do |t|
11
+ t.references :subject, polymorphic: true, null: false, type: uuid_column_type, index: true
12
+ t.column :reviewer_id, uuid_column_type, null: false
8
13
  t.string :review_status, null: false, default: "pending"
9
14
  t.text :notes
10
- t.jsonb :context_json, null: false, default: {}
15
+ t.send(json_column_type, :context_json, null: false, default: {})
11
16
  t.string :context_digest
12
17
  t.integer :content_revision_version, null: false, default: 0
13
- t.jsonb :metadata_json, null: false, default: {}
18
+ t.send(json_column_type, :metadata_json, null: false, default: {})
14
19
 
15
20
  t.timestamps
16
21
  end
@@ -20,8 +25,8 @@ class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[8.1]
20
25
  add_index :annotation_reviews, :context_digest
21
26
  add_index :annotation_reviews, :reviewer_id
22
27
 
23
- create_table :annotation_spans, id: :uuid do |t|
24
- t.references :annotation_review, null: false, type: :uuid, foreign_key: {to_table: :annotation_reviews}, index: true
28
+ create_table :annotation_spans, id: uuid_column_type do |t|
29
+ t.references :annotation_review, null: false, type: uuid_column_type, foreign_key: {to_table: :annotation_reviews}, index: true
25
30
  t.string :field_name, null: false
26
31
  t.text :selected_text, null: false
27
32
  t.integer :start_offset, null: false
@@ -29,8 +34,8 @@ class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[8.1]
29
34
  t.text :comment, null: false
30
35
  t.string :category
31
36
  t.integer :content_revision_version, null: false, default: 0
32
- t.jsonb :context_paths_json, null: false, default: []
33
- t.jsonb :metadata_json, null: false, default: {}
37
+ t.send(json_column_type, :context_paths_json, null: false, default: [])
38
+ t.send(json_column_type, :metadata_json, null: false, default: {})
34
39
 
35
40
  t.timestamps
36
41
  end
@@ -38,4 +43,16 @@ class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[8.1]
38
43
  add_index :annotation_spans, :category
39
44
  add_index :annotation_spans, :field_name
40
45
  end
46
+
47
+ private
48
+
49
+ def enable_pgcrypto_for_postgres
50
+ return unless postgres?
51
+
52
+ enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
53
+ end
54
+
55
+ def postgres?
56
+ connection.adapter_name.match?(/postgres/i)
57
+ end
41
58
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class AddMetadataJsonGinIndexToAnnotationReviews < ActiveRecord::Migration[8.1]
3
+ class AddMetadataJsonGinIndexToAnnotationReviews < ActiveRecord::Migration[7.1]
4
4
  def change
5
5
  return unless connection.adapter_name.match?(/postgres/i)
6
6
 
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class AddPanelQueryIndexToAnnotationSpans < ActiveRecord::Migration[8.1]
3
+ class AddPanelQueryIndexToAnnotationSpans < ActiveRecord::Migration[7.1]
4
4
  def change
5
5
  add_index :annotation_spans,
6
6
  %i[annotation_review_id field_name content_revision_version],
@@ -40,7 +40,6 @@ module ActiveAdmin::Annotations
40
40
  "Review",
41
41
  "Subject: #{review.subject_type} ##{review.subject_id}",
42
42
  "Status: #{review.review_status}",
43
- "Reviewer ID: #{review.reviewer_id}",
44
43
  "Review ID: #{review.id}"
45
44
  ]
46
45
  if content_revision_enabled?
@@ -25,6 +25,18 @@ module ActiveAdmin
25
25
  to: "activeadmin_annotations/annotator_controller.js"
26
26
  importmap.pin "controllers/activeadmin_annotations/clipboard_controller",
27
27
  to: "activeadmin_annotations/clipboard_controller.js"
28
+ importmap.pin "activeadmin_annotations/selection",
29
+ to: "activeadmin_annotations/selection.js"
30
+ importmap.pin "activeadmin_annotations/highlights",
31
+ to: "activeadmin_annotations/highlights.js"
32
+ importmap.pin "activeadmin_annotations/annotation_list",
33
+ to: "activeadmin_annotations/annotation_list.js"
34
+ importmap.pin "activeadmin_annotations/annotation_client",
35
+ to: "activeadmin_annotations/annotation_client.js"
36
+ importmap.pin "activeadmin_annotations/composer",
37
+ to: "activeadmin_annotations/composer.js"
38
+ importmap.pin "activeadmin_annotations/annotator_logic",
39
+ to: "activeadmin_annotations/annotator_logic.mjs"
28
40
  end
29
41
 
30
42
  Rails.application.importmap.draw(&pin_controller)
@@ -33,7 +45,10 @@ module ActiveAdmin
33
45
 
34
46
  initializer "activeadmin_annotations.assets" do |app|
35
47
  assets_path = root.join("app/assets")
36
- app.config.importmap.cache_sweepers << assets_path.join("controllers") if app.config.respond_to?(:importmap)
48
+ if app.config.respond_to?(:importmap)
49
+ app.config.importmap.cache_sweepers << assets_path.join("controllers")
50
+ app.config.importmap.cache_sweepers << assets_path.join("javascripts")
51
+ end
37
52
 
38
53
  assets = app.config.assets if app.config.respond_to?(:assets)
39
54
  assets.precompile << "activeadmin_annotations.css" if assets&.respond_to?(:precompile)
@@ -36,7 +36,7 @@ module ActiveAdmin::Annotations
36
36
 
37
37
  def export_relation_in_batches
38
38
  reviews.find_in_batches(batch_size: BATCH_SIZE) do |batch|
39
- ActiveRecord::Associations::Preloader.new(records: batch, associations: :annotations).call
39
+ ActiveRecord::Associations::Preloader.new(records: batch, associations: [:annotations, :subject]).call
40
40
  batch.each do |review|
41
41
  rows_for(review).each { |row| yield row }
42
42
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin::Annotations
4
+ class PanelHtml
5
+ def self.prepare(content)
6
+ return "".html_safe if content.nil?
7
+
8
+ html = content.to_s
9
+ prepared = if content.respond_to?(:html_safe?) && content.html_safe?
10
+ sanitizer.sanitize(html)
11
+ else
12
+ ERB::Util.html_escape(html)
13
+ end
14
+ prepared.html_safe
15
+ end
16
+
17
+ def self.sanitizer
18
+ Rails::HTML::Sanitizer.safe_list_sanitizer.new
19
+ end
20
+ end
21
+ end
@@ -18,8 +18,20 @@ module ActiveAdmin::Annotations
18
18
  review
19
19
  end
20
20
 
21
+ def self.subject_for_span_create!(subject_type:, subject_id:)
22
+ raise NotFound, "Subject not found" if subject_type.blank? || subject_id.blank?
23
+
24
+ klass = subject_type.to_s.safe_constantize
25
+ raise NotFound, "Subject not found" unless klass.is_a?(Class) && klass < ActiveRecord::Base
26
+
27
+ subject = klass.find_by(id: subject_id)
28
+ raise NotFound, "Subject not found" unless subject
29
+
30
+ subject
31
+ end
32
+
21
33
  def self.authorize_annotation!(annotation:, reviewer:)
22
- raise Forbidden, "Annotation not found" unless annotation
34
+ raise NotFound, "Annotation not found" unless annotation
23
35
  raise Forbidden, "Annotation access denied" unless reviewer.present? &&
24
36
  annotation.annotation_review.reviewer_id == reviewer.id
25
37