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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/CODE_OF_CONDUCT.md +14 -19
- data/CONTRIBUTING.md +9 -15
- data/GOVERNANCE.md +10 -14
- data/README.md +24 -7
- data/SECURITY.md +14 -16
- data/activeadmin_annotations.gemspec +2 -2
- data/admin/annotation_reviews.rb +14 -6
- data/admin/annotations.rb +33 -13
- data/app/assets/controllers/activeadmin_annotations/annotation_client.js +44 -0
- data/app/assets/controllers/activeadmin_annotations/annotation_list.js +29 -0
- data/app/assets/controllers/activeadmin_annotations/annotator_controller.js +45 -370
- data/app/assets/controllers/activeadmin_annotations/composer.js +179 -0
- data/app/assets/controllers/activeadmin_annotations/highlights.js +76 -0
- data/app/assets/controllers/activeadmin_annotations/selection.js +73 -0
- data/app/assets/javascripts/activeadmin_annotations/annotator_logic.mjs +73 -0
- data/app/assets/stylesheets/activeadmin_annotations.css +15 -0
- data/app/helpers/active_admin/annotations/panel_helper.rb +15 -8
- data/app/models/active_admin/annotations/review.rb +7 -2
- data/app/services/active_admin/annotations/review_service.rb +34 -27
- data/app/views/active_admin/annotations/_panel.html.erb +15 -2
- data/db/migrate/20260617120000_create_activeadmin_annotations_tables.rb +27 -10
- data/db/migrate/20260706150000_add_metadata_json_gin_index_to_annotation_reviews.rb +1 -1
- data/db/migrate/20260710120000_add_panel_query_index_to_annotation_spans.rb +1 -1
- data/lib/activeadmin/annotations/copy_text.rb +0 -1
- data/lib/activeadmin/annotations/engine.rb +16 -1
- data/lib/activeadmin/annotations/exporter.rb +1 -1
- data/lib/activeadmin/annotations/panel_html.rb +21 -0
- data/lib/activeadmin/annotations/review_access.rb +13 -1
- data/lib/activeadmin/annotations/review_panel.rb +9 -6
- data/lib/activeadmin/annotations/span_create.rb +70 -0
- data/lib/activeadmin/annotations/version.rb +1 -1
- data/lib/activeadmin_annotations.rb +2 -0
- 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("&", "&")
|
|
4
|
+
.replaceAll("<", "<")
|
|
5
|
+
.replaceAll(">", ">")
|
|
6
|
+
.replaceAll('"', """)
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
-
|
|
33
|
+
metadata_flag("needs_follow_up")
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def context_stale?
|
|
37
|
-
|
|
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
|
|
56
|
-
|
|
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
|
|
63
|
+
def assemble_review!(
|
|
77
64
|
subject:,
|
|
78
65
|
reviewer:,
|
|
79
66
|
context:,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
<%
|
|
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
|
-
|
|
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[
|
|
3
|
+
class CreateActiveadminAnnotationsTables < ActiveRecord::Migration[7.1]
|
|
4
4
|
def change
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
24
|
-
t.references :annotation_review, null: false, type:
|
|
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.
|
|
33
|
-
t.
|
|
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[
|
|
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[
|
|
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],
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|