libreqda 1.2.0__py3-none-any.whl

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 (255) hide show
  1. CHANGELOG.md +481 -0
  2. libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
  3. libreqda-1.2.0.dist-info/METADATA +131 -0
  4. libreqda-1.2.0.dist-info/RECORD +255 -0
  5. libreqda-1.2.0.dist-info/WHEEL +4 -0
  6. libreqda-1.2.0.dist-info/entry_points.txt +4 -0
  7. scripts/babelrc +8 -0
  8. scripts/dist/LICENSE.txt +33 -0
  9. scripts/dist/RELEASE.txt +23 -0
  10. scripts/dist/macos/libreqda.icns +0 -0
  11. scripts/dist/macos/macos.spec +43 -0
  12. scripts/dist/macos/plist.patch +10 -0
  13. scripts/dist/macos/taguette.icns +0 -0
  14. scripts/dist/macos/taguette_console_wrapper +4 -0
  15. scripts/dist/macos.sh +32 -0
  16. scripts/dist/pyinstaller_entrypoint.py +19 -0
  17. scripts/dist/windows/windows-installer.iss +51 -0
  18. scripts/dist/windows/windows.spec +39 -0
  19. scripts/edugain/worker_update_edugain_idps.py +89 -0
  20. scripts/edugain/worker_update_edugain_idps.sh +9 -0
  21. scripts/make_password_reset_link.py +56 -0
  22. scripts/make_theme.py +99 -0
  23. scripts/new_db_revision.sh +8 -0
  24. scripts/register_user.py +76 -0
  25. scripts/update_pot.sh +33 -0
  26. scripts/update_translations.sh +17 -0
  27. scripts/validate.py +140 -0
  28. scripts/wipe_and_disable_user.py +90 -0
  29. taguette/__init__.py +13 -0
  30. taguette/access_control.py +145 -0
  31. taguette/convert.py +623 -0
  32. taguette/database/__init__.py +177 -0
  33. taguette/database/base.py +9 -0
  34. taguette/database/copy.py +433 -0
  35. taguette/database/models.py +920 -0
  36. taguette/demodata.py +688 -0
  37. taguette/export.py +500 -0
  38. taguette/extract.py +287 -0
  39. taguette/import_codebook.py +110 -0
  40. taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
  41. taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
  42. taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
  43. taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
  44. taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
  45. taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
  46. taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
  47. taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
  48. taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
  49. taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
  50. taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
  51. taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
  52. taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
  53. taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
  54. taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
  55. taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
  56. taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
  57. taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
  58. taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
  59. taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
  60. taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
  61. taguette/loki_handler.py +107 -0
  62. taguette/main.py +649 -0
  63. taguette/migrations/env.py +72 -0
  64. taguette/migrations/script.py.mako +24 -0
  65. taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
  66. taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
  67. taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
  68. taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
  69. taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
  70. taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
  71. taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
  72. taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
  73. taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
  74. taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
  75. taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
  76. taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
  77. taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
  78. taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
  79. taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
  80. taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
  81. taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
  82. taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
  83. taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
  84. taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
  85. taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
  86. taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
  87. taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
  88. taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
  89. taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
  90. taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
  91. taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
  92. taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
  93. taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
  94. taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
  95. taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
  96. taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
  97. taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
  98. taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
  99. taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
  100. taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
  101. taguette/static/README.md +34 -0
  102. taguette/static/alt_templates/.gitkeep +0 -0
  103. taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
  104. taguette/static/css/custom.css +1696 -0
  105. taguette/static/css/fa-all.min.css +5 -0
  106. taguette/static/css/themed.css +3 -0
  107. taguette/static/css/themed.css.map +25 -0
  108. taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
  109. taguette/static/css/vendor/bootstrap.min.css +6 -0
  110. taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
  111. taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
  112. taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
  113. taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
  114. taguette/static/css/vendor/hierarchy-select.min.css +1 -0
  115. taguette/static/css/vendor/introjs.min.css +2 -0
  116. taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
  117. taguette/static/favicon.ico +0 -0
  118. taguette/static/js/codebook-viz.js +428 -0
  119. taguette/static/js/collection/context-menu.js +180 -0
  120. taguette/static/js/collection/hierarchy-select.js +71 -0
  121. taguette/static/js/collection/manager.js +217 -0
  122. taguette/static/js/document/context-menu.js +92 -0
  123. taguette/static/js/document/custom-select.js +82 -0
  124. taguette/static/js/document/manager.js +943 -0
  125. taguette/static/js/document/treeview.js +593 -0
  126. taguette/static/js/highlight/README.md +18 -0
  127. taguette/static/js/highlight/crud.js +119 -0
  128. taguette/static/js/highlight/extracts.js +497 -0
  129. taguette/static/js/highlight/loader.js +109 -0
  130. taguette/static/js/highlight/renderer.js +405 -0
  131. taguette/static/js/login.js +15 -0
  132. taguette/static/js/permissions.js +153 -0
  133. taguette/static/js/polling-event.js +145 -0
  134. taguette/static/js/project/intro.js +524 -0
  135. taguette/static/js/project/manager.js +0 -0
  136. taguette/static/js/project/search.js +124 -0
  137. taguette/static/js/tag/context-menu.js +133 -0
  138. taguette/static/js/tag/explore-codes.js +400 -0
  139. taguette/static/js/tag/hierarchy-select.js +69 -0
  140. taguette/static/js/tag/manager.js +545 -0
  141. taguette/static/js/tag/treeview.js +613 -0
  142. taguette/static/js/taguette.js +1790 -0
  143. taguette/static/js/utilities.js +714 -0
  144. taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
  145. taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
  146. taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
  147. taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
  148. taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
  149. taguette/static/js/vendor/hierarchy-select.min.js +1 -0
  150. taguette/static/js/vendor/introjs.min.js +17 -0
  151. taguette/static/js/vendor/jquery-3.7.1.js +2 -0
  152. taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
  153. taguette/static/libreqda.ico +0 -0
  154. taguette/static/logo-h.png +0 -0
  155. taguette/static/logo.png +0 -0
  156. taguette/static/logo_blanc.png +0 -0
  157. taguette/static/missing.png +0 -0
  158. taguette/static/robots.txt +2 -0
  159. taguette/static/scss/components/_badge.scss +42 -0
  160. taguette/static/scss/components/_button.scss +169 -0
  161. taguette/static/scss/components/_checkbox.scss +40 -0
  162. taguette/static/scss/components/_context_menu.scss +28 -0
  163. taguette/static/scss/components/_custom_select.scss +150 -0
  164. taguette/static/scss/components/_dropdown.scss +43 -0
  165. taguette/static/scss/components/_form.scss +54 -0
  166. taguette/static/scss/components/_modal.scss +57 -0
  167. taguette/static/scss/components/_project_card.scss +163 -0
  168. taguette/static/scss/components/_tag.scss +308 -0
  169. taguette/static/scss/components/_treeview.scss +76 -0
  170. taguette/static/scss/custom.scss +809 -0
  171. taguette/static/scss/layout/_credits.scss +62 -0
  172. taguette/static/scss/layout/_footer.scss +6 -0
  173. taguette/static/scss/layout/_navigation.scss +165 -0
  174. taguette/static/scss/layout/_sidebar.scss +37 -0
  175. taguette/static/scss/themes/default/_variables.scss +103 -0
  176. taguette/static/scss/themes/default/theme.css +3 -0
  177. taguette/static/scss/themes/default/theme.css.map +26 -0
  178. taguette/static/scss/themes/default/theme.scss +4 -0
  179. taguette/static/webfonts/BadScript-OFL.txt +94 -0
  180. taguette/static/webfonts/BadScript.ttf +0 -0
  181. taguette/static/webfonts/Montserrat-Light.ttf +0 -0
  182. taguette/static/webfonts/Montserrat-OFL.txt +93 -0
  183. taguette/static/webfonts/fa-brands-400.eot +0 -0
  184. taguette/static/webfonts/fa-brands-400.svg +1184 -0
  185. taguette/static/webfonts/fa-brands-400.ttf +0 -0
  186. taguette/static/webfonts/fa-brands-400.woff +0 -0
  187. taguette/static/webfonts/fa-brands-400.woff2 +0 -0
  188. taguette/static/webfonts/fa-regular-400.eot +0 -0
  189. taguette/static/webfonts/fa-regular-400.svg +467 -0
  190. taguette/static/webfonts/fa-regular-400.ttf +0 -0
  191. taguette/static/webfonts/fa-regular-400.woff +0 -0
  192. taguette/static/webfonts/fa-regular-400.woff2 +0 -0
  193. taguette/static/webfonts/fa-solid-900.eot +0 -0
  194. taguette/static/webfonts/fa-solid-900.svg +2618 -0
  195. taguette/static/webfonts/fa-solid-900.ttf +0 -0
  196. taguette/static/webfonts/fa-solid-900.woff +0 -0
  197. taguette/static/webfonts/fa-solid-900.woff2 +0 -0
  198. taguette/templates/alt_templates/.gitkeep +0 -0
  199. taguette/templates/archived_projects.html +50 -0
  200. taguette/templates/auth/account.html +74 -0
  201. taguette/templates/auth/cilogon.login.html +89 -0
  202. taguette/templates/auth/email_reset_password.html +5 -0
  203. taguette/templates/auth/external_account.html +60 -0
  204. taguette/templates/auth/login.html +144 -0
  205. taguette/templates/auth/reset_password.html +27 -0
  206. taguette/templates/auth/token_needed.html +7 -0
  207. taguette/templates/base.html +209 -0
  208. taguette/templates/bug_report.html +158 -0
  209. taguette/templates/cookies_prompt.html +15 -0
  210. taguette/templates/downloads.html +127 -0
  211. taguette/templates/email_bug_report.txt +9 -0
  212. taguette/templates/email_reset_password.txt +5 -0
  213. taguette/templates/error.html +8 -0
  214. taguette/templates/export_codebook.html +20 -0
  215. taguette/templates/export_document.html +20 -0
  216. taguette/templates/export_highlights.html +47 -0
  217. taguette/templates/index.html +347 -0
  218. taguette/templates/maintenance.html +8 -0
  219. taguette/templates/messages.js +74 -0
  220. taguette/templates/modal/change_document.html +68 -0
  221. taguette/templates/modal/create_collection.html +100 -0
  222. taguette/templates/modal/create_document.html +84 -0
  223. taguette/templates/modal/create_highlight.html +38 -0
  224. taguette/templates/modal/create_tag.html +73 -0
  225. taguette/templates/modal/explore_codes.html +58 -0
  226. taguette/templates/modal/manage_collaborators.html +44 -0
  227. taguette/templates/modal/merge_tag.html +49 -0
  228. taguette/templates/new_password.html +32 -0
  229. taguette/templates/partials/alert.html +16 -0
  230. taguette/templates/partials/codebook_viz_modal.html +34 -0
  231. taguette/templates/partials/confirm.html +17 -0
  232. taguette/templates/partials/document_toolbar.html +37 -0
  233. taguette/templates/partials/hlinfo.html +13 -0
  234. taguette/templates/partials/project_card.html +44 -0
  235. taguette/templates/partials/project_card_archived.html +41 -0
  236. taguette/templates/partials/project_sidebar.html +99 -0
  237. taguette/templates/partials/spinner.html +11 -0
  238. taguette/templates/permissions.html +54 -0
  239. taguette/templates/project.html +288 -0
  240. taguette/templates/project_delete.html +17 -0
  241. taguette/templates/project_import.html +122 -0
  242. taguette/templates/project_import_codebook.html +34 -0
  243. taguette/templates/project_import_codebook_confirm.html +49 -0
  244. taguette/templates/project_new.html +31 -0
  245. taguette/templates/tos.html +8 -0
  246. taguette/templates/trans.js +7 -0
  247. taguette/templates/welcome.html +15 -0
  248. taguette/utils.py +177 -0
  249. taguette/validate.py +141 -0
  250. taguette/version_check.py +102 -0
  251. taguette/web/__init__.py +252 -0
  252. taguette/web/api.py +1835 -0
  253. taguette/web/base.py +864 -0
  254. taguette/web/export.py +338 -0
  255. taguette/web/views.py +1618 -0
@@ -0,0 +1,119 @@
1
+ function editHighlight() {
2
+ const selection = describeSelection();
3
+ if (selection) {
4
+ createHighlight(selection);
5
+ return;
6
+ }
7
+
8
+ highlightModalReset();
9
+ const id = this.dataset.highlightId;
10
+ const highlight = highlights['' + id];
11
+
12
+ if (!highlight) {
13
+ log('Highlight not found: ' + id, 'error');
14
+ return;
15
+ }
16
+
17
+ document.getElementById('highlight-add-id').value = id;
18
+ document.getElementById('highlight-add-start').value = highlight.start_offset;
19
+ document.getElementById('highlight-add-end').value = highlight.end_offset;
20
+
21
+ const hl_tags = highlight.tags;
22
+ const allNodes = treeview_tags_highlight.treeview('getEnabled');
23
+
24
+ for (const tagId of hl_tags) {
25
+ highlightSelectedTags.push(tagId);
26
+ const node = allNodes.find(n => n.id === tagId);
27
+ if (node) treeview_tags_highlight.treeview('selectNode', node.nodeId);
28
+ }
29
+
30
+ showHighlightTagSelection();
31
+ $(highlight_add_modal).modal('show').drags({handle: '.modal-header'});
32
+ }
33
+
34
+ function addHighlight(highlight, documentId) {
35
+ const targetDoc = documentId || current_document;
36
+ if (targetDoc !== current_document) {
37
+ updateDocumentHighlightsCount(targetDoc, +1);
38
+ return;
39
+ }
40
+ const id = '' + highlight.id;
41
+ const isNew = !highlights[id];
42
+ highlights[id] = highlight;
43
+ renderHighlights(Object.values(highlights));
44
+ if (isNew) {
45
+ updateDocumentHighlightsCount(targetDoc, +1);
46
+ }
47
+ }
48
+
49
+ function removeHighlight(highlightId, documentId) {
50
+ const targetDoc = documentId || current_document;
51
+ if (targetDoc !== current_document) {
52
+ updateDocumentHighlightsCount(targetDoc, -1);
53
+ return;
54
+ }
55
+ const id = '' + highlightId;
56
+ if (!highlights[id]) return;
57
+ delete highlights[id];
58
+ renderHighlights(Object.values(highlights));
59
+ updateDocumentHighlightsCount(targetDoc, -1);
60
+ }
61
+
62
+ function updateDocumentHighlightsCount(documentId, delta) {
63
+ if (!documentId) return;
64
+ const doc = documents['' + documentId];
65
+ if (!doc) return;
66
+ doc.highlights_count = Math.max(0, (doc.highlights_count || 0) + delta);
67
+ if (typeof initDocumentsTreeview === 'function') {
68
+ initDocumentsTreeview();
69
+ }
70
+ }
71
+
72
+ function saveHighlightDragDrop(tagId) {
73
+ const selection = globalThis.getSelection();
74
+ if (!selection?.rangeCount || selection.isCollapsed) return;
75
+
76
+ const range = selection.getRangeAt(0);
77
+ const container = document.getElementById('highlight-content');
78
+ if (!container?.contains(range.commonAncestorContainer)) return;
79
+
80
+ const snippet = range.toString().trim();
81
+ if (!snippet) return;
82
+
83
+ const beforeRange = document.createRange();
84
+ beforeRange.selectNodeContents(container);
85
+ beforeRange.setEnd(range.startContainer, range.startOffset);
86
+
87
+ const afterRange = document.createRange();
88
+ afterRange.selectNodeContents(container);
89
+ afterRange.setStart(range.endContainer, range.endOffset);
90
+
91
+ const startOffset = lengthUTF8(beforeRange.toString());
92
+ const endOffset = startOffset + lengthUTF8(snippet);
93
+
94
+ submitHighLightCreation(
95
+ { selection: [startOffset, endOffset], tags: [tagId] },
96
+ reply => addHighlight(reply),
97
+ error => {
98
+ log("Failed to create highlight", 'error', error);
99
+ const createHighlightErrorMessage = gettext("Couldn't create highlight!");
100
+ showAlert(`${createHighlightErrorMessage}`, 'error');
101
+ }
102
+ );
103
+ }
104
+
105
+ function submitHighLightCreation(highlight, callbackSuccess, callbackFail) {
106
+ const { id, selection, tags } = highlight;
107
+ const requestData = {
108
+ start_offset: selection[0],
109
+ end_offset: selection[1],
110
+ tags: tags,
111
+ };
112
+
113
+ const url = id
114
+ ? `/api/project/${project_id}/document/${current_document}/highlight/${id}`
115
+ : `/api/project/${project_id}/document/${current_document}/highlight/new`;
116
+
117
+ showSpinner();
118
+ postJSON(url, requestData).then(callbackSuccess).catch(callbackFail).finally(hideSpinner);
119
+ }
@@ -0,0 +1,497 @@
1
+ function showTagUnderlineTooltip(event, tagId, color) {
2
+ let tooltip = document.getElementById('tag-underline-tooltip');
3
+
4
+ if (!tooltip) {
5
+ tooltip = document.createElement('div');
6
+ tooltip.id = 'tag-underline-tooltip';
7
+ document.body.appendChild(tooltip);
8
+
9
+ Object.assign(tooltip.style, {
10
+ position: 'fixed',
11
+ padding: '10px',
12
+ background: '#fff',
13
+ border: '2px solid #333',
14
+ borderRadius: '6px',
15
+ boxShadow: '0 4px 12px rgba(0,0,0,0.2)',
16
+ fontSize: '13px',
17
+ pointerEvents: 'none',
18
+ zIndex: '10000',
19
+ maxWidth: '300px',
20
+ wordWrap: 'break-word',
21
+ overflowWrap: 'break-word',
22
+ });
23
+ }
24
+
25
+ const tag = findTag(tagId);
26
+ if (!tag) return;
27
+
28
+ tooltip.innerHTML = `
29
+ <div style="display: flex; gap: 4px; flex-wrap: wrap; max-width: 280px;">
30
+ <div style="
31
+ display: inline-block;
32
+ padding: 4px 8px;
33
+ border-radius: 3px;
34
+ background-color: ${color};
35
+ color: white;
36
+ font-size: 11px;
37
+ max-width: 100%;
38
+ word-wrap: break-word;
39
+ overflow-wrap: break-word;
40
+ ">
41
+ ${tag.path}
42
+ </div>
43
+ </div>
44
+ `;
45
+
46
+ tooltip.style.display = 'block';
47
+ tooltip.style.left = (event.clientX + 10) + 'px';
48
+ tooltip.style.top = (event.clientY + 10) + 'px';
49
+ }
50
+
51
+ function hideTagUnderlineTooltip() {
52
+ const tooltip = document.getElementById('tag-underline-tooltip');
53
+ if (tooltip) tooltip.style.display = 'none';
54
+ }
55
+
56
+ /**
57
+ * Apply colored SVG underlines to a content div based on tag_ranges.
58
+ * Uses Range.getClientRects() to get pixel positions, then draws SVG lines.
59
+ */
60
+ function applyTagUnderlines(contentDiv, tagRanges, principalTagIds) {
61
+ if (!tagRanges || tagRanges.length === 0) return;
62
+
63
+ // Make contentDiv a positioning context for the SVG overlay
64
+ contentDiv.style.position = 'relative';
65
+
66
+ const textNodes = collectTextNodes(contentDiv);
67
+ const fullText = textNodes.map(n => n.nodeValue).join('');
68
+ if (fullText.length === 0) return;
69
+
70
+ // Build char index -> { node, offsetInNode } mapping
71
+ const charMap = [];
72
+ for (const textNode of textNodes) {
73
+ const nodeText = textNode.nodeValue;
74
+ for (let j = 0; j < nodeText.length; j++) {
75
+ charMap.push({ node: textNode, offset: j });
76
+ }
77
+ }
78
+
79
+ // Convert UTF-8 byte offsets to char indices
80
+ const utf8Map = buildUTF8ToCharMap(fullText);
81
+ const ranges = tagRanges.map(tr => ({
82
+ tag_id: tr.tag_id,
83
+ color: tr.color,
84
+ startChar: getCharIndexFromUTF8Index(fullText, tr.start, utf8Map),
85
+ endChar: getCharIndexFromUTF8Index(fullText, tr.end, utf8Map),
86
+ }));
87
+
88
+ // Sort by start, then larger ranges first
89
+ ranges.sort((a, b) => a.startChar - b.startChar || (b.endChar - b.startChar) - (a.endChar - a.startChar));
90
+
91
+ // Assign lanes for overlapping ranges
92
+ const laneEnds = [];
93
+ const rangeLanes = ranges.map(range => {
94
+ for (let lane = 0; lane < laneEnds.length; lane++) {
95
+ if (laneEnds[lane] <= range.startChar) {
96
+ laneEnds[lane] = range.endChar;
97
+ return lane;
98
+ }
99
+ }
100
+ laneEnds.push(range.endChar);
101
+ return laneEnds.length - 1;
102
+ });
103
+
104
+ const totalLanes = laneEnds.length;
105
+ const LINE_HEIGHT = 3;
106
+ const LINE_GAP = 2;
107
+ const BASE_OFFSET = 2;
108
+ const extraSpace = BASE_OFFSET + totalLanes * (LINE_HEIGHT + LINE_GAP);
109
+
110
+ // Add bottom padding so SVG lines don't overlap the badges
111
+ contentDiv.style.paddingBottom = extraSpace + 'px';
112
+
113
+ // Increase line-height to make room for stacked underlines between text lines
114
+ if (totalLanes > 0) {
115
+ contentDiv.style.lineHeight = (1.6 + totalLanes * 0.3) + 'em';
116
+ }
117
+
118
+ // Create SVG overlay
119
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
120
+ svg.classList.add('tag-underline-svg');
121
+ svg.style.position = 'absolute';
122
+ svg.style.left = '0';
123
+ svg.style.top = '0';
124
+ svg.style.width = '100%';
125
+ svg.style.height = '100%';
126
+ svg.style.pointerEvents = 'none';
127
+ svg.style.overflow = 'visible';
128
+
129
+ const containerRect = contentDiv.getBoundingClientRect();
130
+
131
+ for (let ri = 0; ri < ranges.length; ri++) {
132
+ const range = ranges[ri];
133
+ const lane = rangeLanes[ri];
134
+ if (range.startChar >= range.endChar || range.startChar >= charMap.length) continue;
135
+
136
+ const effectiveEnd = Math.min(range.endChar, charMap.length);
137
+
138
+ // Create a DOM Range to get bounding rects
139
+ const domRange = document.createRange();
140
+ const startEntry = charMap[range.startChar];
141
+ const endEntry = charMap[effectiveEnd - 1];
142
+ domRange.setStart(startEntry.node, startEntry.offset);
143
+ domRange.setEnd(endEntry.node, endEntry.offset + 1);
144
+
145
+ const rawRects = Array.from(domRange.getClientRects()).map(r => ({
146
+ x: r.left,
147
+ y: r.top,
148
+ width: r.width,
149
+ height: r.height,
150
+ }));
151
+ const rects = deduplicateRects(rawRects);
152
+
153
+ for (const rect of rects) {
154
+ const x = rect.x - containerRect.left;
155
+ const y = (rect.y + rect.height) - containerRect.top + BASE_OFFSET + lane * (LINE_HEIGHT + LINE_GAP);
156
+
157
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
158
+ line.setAttribute('x1', x);
159
+ line.setAttribute('y1', y + LINE_HEIGHT / 2);
160
+ line.setAttribute('x2', x + rect.width);
161
+ line.setAttribute('y2', y + LINE_HEIGHT / 2);
162
+ line.setAttribute('stroke', range.color);
163
+ line.setAttribute('stroke-width', LINE_HEIGHT);
164
+ line.setAttribute('stroke-linecap', 'round');
165
+ if (!principalTagIds.has(range.tag_id)) {
166
+ line.setAttribute('stroke-dasharray', '8 6');
167
+ line.setAttribute('stroke-linecap', 'butt');
168
+ line.setAttribute('data-overlay-tag-id', range.tag_id);
169
+ line.style.display = 'none';
170
+ }
171
+ line.classList.add('smooth-stroke');
172
+ line.style.pointerEvents = 'all';
173
+ line.style.cursor = 'pointer';
174
+ line.style.transition = 'stroke-width 0.2s';
175
+
176
+ line.addEventListener('mouseenter', function (e) {
177
+ line.setAttribute('stroke-width', LINE_HEIGHT + 2);
178
+ showTagUnderlineTooltip(e, range.tag_id, range.color);
179
+ });
180
+ line.addEventListener('mouseleave', function () {
181
+ line.setAttribute('stroke-width', LINE_HEIGHT);
182
+ hideTagUnderlineTooltip();
183
+ });
184
+
185
+ svg.appendChild(line);
186
+ }
187
+ }
188
+
189
+ contentDiv.appendChild(svg);
190
+ }
191
+
192
+ function loadTag(tag_path, page, minSpinnerMs) {
193
+ if (page === undefined) {
194
+ page = 1;
195
+ }
196
+ let spinnerShownAt;
197
+ const useProgressBar = tag_path !== undefined && minSpinnerMs !== undefined;
198
+ if(tag_path !== undefined) {
199
+ spinnerShownAt = Date.now();
200
+ showSpinner(null, useProgressBar);
201
+ if (useProgressBar) {
202
+ // Show the progress bar alone, not the spinner circle, for this cue.
203
+ document.getElementById('spinner').style.display = 'none';
204
+
205
+ // Drive the bar to 100% ourselves over minSpinnerMs, since this isn't
206
+ // a real byte-progress upload — just a deliberate "computing" cue.
207
+ const bar = document.querySelector('#progress .progress-bar');
208
+ bar.style.transition = 'none';
209
+ bar.style.width = '0%';
210
+ void bar.offsetWidth; // force reflow so the 0% is committed before animating
211
+ bar.style.transition = `width ${minSpinnerMs}ms linear`;
212
+ bar.style.width = '100%';
213
+ }
214
+ }
215
+ getJSON(
216
+ `/api/project/${project_id}/highlights/${encodeURIComponent(tag_path)}?page=${page}`
217
+ )
218
+ .then(function (result) {
219
+ log(`Loaded highlights for tag ${tag_path}`);
220
+ document_contents.style.direction = 'ltr';
221
+ current_tag = tag_path;
222
+ // current_document = null;
223
+ let document_links = document.getElementsByClassName('document-link-current');
224
+ for (let i = document_links.length - 1; i >= 0; --i) {
225
+ document_links[i].classList.remove('document-link-current');
226
+ }
227
+ // No need to clear the 'tag-current', we are calling updateTagsList() below
228
+ document_contents.innerHTML = '';
229
+ highlights = {};
230
+ for (const hl of result.highlights) {
231
+ let content = document.createElement('div');
232
+ if (hl.text_direction === 'RIGHT_TO_LEFT') {
233
+ content.style.direction = 'rtl';
234
+ } else {
235
+ content.style.direction = 'ltr';
236
+ }
237
+ content.innerHTML = hl.content;
238
+ let elem = document.createElement('div');
239
+ elem.className = 'highlight-entry';
240
+ elem.setAttribute('id', 'highlight-entry-' + hl.id);
241
+ elem.appendChild(content);
242
+ elem.appendChild(document.createTextNode(' '));
243
+
244
+ // Separate principal tags (the tag being viewed) from associated tags
245
+ // When tag_path contains operators (∩∪¬), extract individual tag paths
246
+ const principalTagIds = new Set();
247
+ const pathParts = tag_path.split(/[∩∪¬]/);
248
+ for (const part of pathParts) {
249
+ const trimmed = part.trim();
250
+ if (trimmed) {
251
+ const t = findTag(trimmed);
252
+ if (t) principalTagIds.add(t.id);
253
+ }
254
+ }
255
+ // Remove NOT-excluded tags from principal set
256
+ const notParts = tag_path.split('¬').slice(1);
257
+ for (const part of notParts) {
258
+ const trimmed = part.split(/[∩∪]/)[0].trim();
259
+ if (trimmed) {
260
+ const t = findTag(trimmed);
261
+ if (t) principalTagIds.delete(t.id);
262
+ }
263
+ }
264
+
265
+ let principalTags = [];
266
+ let associatedTags = [];
267
+ hl.tags.forEach(function (tagId) {
268
+ const tag = findTag(tagId);
269
+ if (tag) {
270
+ if (principalTagIds.has(tag.id)) {
271
+ principalTags.push(tag);
272
+ } else {
273
+ associatedTags.push(tag);
274
+ }
275
+ }
276
+ });
277
+
278
+ // Row 1: doclink + principal tag badges
279
+ let badgeRow = document.createElement('div');
280
+ badgeRow.className = 'd-flex flex-wrap gap-1 align-items-center';
281
+
282
+ let doclink = document.createElement('a');
283
+ doclink.className = 'badge badge-primary';
284
+ const documentPath = buildDocumentPath(hl.document_id);
285
+ doclink.textContent = documentPath;
286
+ linkDocument(doclink, hl.document_id, hl.id);
287
+ badgeRow.appendChild(doclink);
288
+
289
+ for (let j = 0; j < principalTags.length; j++) {
290
+ let taglink = document.createElement('a');
291
+ taglink.className = 'badge';
292
+ taglink.setAttribute('style', `background-color: ${principalTags[j].color}; color: white; font-weight: bold;`);
293
+ taglink.textContent = principalTags[j].path;
294
+ badgeRow.appendChild(taglink);
295
+ }
296
+
297
+ elem.appendChild(badgeRow);
298
+
299
+ // Row 2: "Associated tags:" label + associated tag badges (only if any)
300
+ if (associatedTags.length > 0) {
301
+ let assocRow = document.createElement('div');
302
+ assocRow.className = 'd-flex flex-wrap gap-2 align-items-center mt-2';
303
+
304
+ let label = document.createElement('small');
305
+ label.style.color = '#6c757d';
306
+ label.style.cursor = 'pointer';
307
+ let allCheck = document.createElement('i');
308
+ allCheck.className = 'far fa-square me-1';
309
+ label.appendChild(allCheck);
310
+ label.appendChild(document.createTextNode(gettext('Overlapping')));
311
+ assocRow.appendChild(label);
312
+
313
+ const syncAllCheck = () => {
314
+ const badges = assocRow.querySelectorAll('.badge-outline-tag');
315
+ const allPinned = badges.length > 0 && Array.from(badges).every(b => b.classList.contains('badge-outline-tag--pinned'));
316
+ allCheck.className = allPinned ? 'fas fa-check-square me-1' : 'far fa-square me-1';
317
+ };
318
+
319
+ label.addEventListener('click', function () {
320
+ const badges = assocRow.querySelectorAll('.badge-outline-tag');
321
+ const allPinned = Array.from(badges).every(b => b.classList.contains('badge-outline-tag--pinned'));
322
+ const newPinned = !allPinned;
323
+ badges.forEach(function (badge) {
324
+ badge.classList.toggle('badge-outline-tag--pinned', newPinned);
325
+ const icon = badge.querySelector('.badge-outline-tag-check');
326
+ icon.className = newPinned
327
+ ? 'fas fa-check-square me-1 badge-outline-tag-check'
328
+ : 'far fa-square me-1 badge-outline-tag-check';
329
+ const tagId = parseInt(badge.getAttribute('data-overlay-tag-id'));
330
+ content.querySelectorAll('line[data-overlay-tag-id="' + tagId + '"]').forEach(function (l) {
331
+ l.style.display = newPinned ? '' : 'none';
332
+ });
333
+ });
334
+ syncAllCheck();
335
+ });
336
+
337
+ associatedTags.sort((a, b) => a.path.localeCompare(b.path));
338
+ for (let j = 0; j < associatedTags.length; j++) {
339
+ let taglink = document.createElement('a');
340
+ taglink.className = 'badge badge-outline-tag';
341
+ taglink.setAttribute('style',
342
+ `border: 2px dashed ${associatedTags[j].color}; color: ${associatedTags[j].color}; background: transparent; font-weight: bold; cursor: pointer;`
343
+ );
344
+ let checkIcon = document.createElement('i');
345
+ checkIcon.className = 'far fa-square me-1 badge-outline-tag-check';
346
+ taglink.appendChild(checkIcon);
347
+ taglink.appendChild(document.createTextNode(associatedTags[j].path));
348
+ taglink.setAttribute('data-overlay-tag-id', associatedTags[j].id);
349
+ taglink.addEventListener('mouseenter', function () {
350
+ const tagId = parseInt(this.getAttribute('data-overlay-tag-id'));
351
+ content.querySelectorAll('line[data-overlay-tag-id="' + tagId + '"]').forEach(function (l) {
352
+ l.style.display = '';
353
+ });
354
+ });
355
+ taglink.addEventListener('mouseleave', function () {
356
+ if (this.classList.contains('badge-outline-tag--pinned')) return;
357
+ const tagId = parseInt(this.getAttribute('data-overlay-tag-id'));
358
+ content.querySelectorAll('line[data-overlay-tag-id="' + tagId + '"]').forEach(function (l) {
359
+ l.style.display = 'none';
360
+ });
361
+ });
362
+ taglink.addEventListener('click', function () {
363
+ const tagId = parseInt(this.getAttribute('data-overlay-tag-id'));
364
+ const pinned = this.classList.toggle('badge-outline-tag--pinned');
365
+ const icon = this.querySelector('.badge-outline-tag-check');
366
+ icon.className = pinned
367
+ ? 'fas fa-check-square me-1 badge-outline-tag-check'
368
+ : 'far fa-square me-1 badge-outline-tag-check';
369
+ content.querySelectorAll('line[data-overlay-tag-id="' + tagId + '"]').forEach(function (l) {
370
+ l.style.display = pinned ? '' : 'none';
371
+ });
372
+ syncAllCheck();
373
+ });
374
+ assocRow.appendChild(taglink);
375
+ }
376
+
377
+ elem.appendChild(assocRow);
378
+ }
379
+
380
+ document_contents.appendChild(elem);
381
+
382
+ // Apply SVG underlines after element is in the DOM (needed for getBoundingClientRect)
383
+ if (hl.tag_ranges && hl.tag_ranges.length > 0) {
384
+ applyTagUnderlines(content, hl.tag_ranges, principalTagIds);
385
+ }
386
+ }
387
+ if (result.highlights.length === 0) {
388
+ document_contents.innerHTML = '<p style="font-style: oblique; text-align: center;">' + gettext("No highlights with this tag yet.") + '</p>';
389
+ }
390
+
391
+ // Pagination controls
392
+ function makePageLink(page_nb, label, current, enabled) {
393
+ let item;
394
+ if (current) {
395
+ item = document.createElement('li');
396
+ item.className = 'page-item active';
397
+ item.innerHTML = '<span class="page-link">' + label + '<span class="sr-only">(current)</span></span>';
398
+ } else if (!enabled) {
399
+ item = document.createElement('li');
400
+ item.className = 'page-item disabled';
401
+ item.innerHTML = '<span class="page-link">' + label + '</span>';
402
+ } else {
403
+ item = document.createElement('li');
404
+ item.className = 'page-item';
405
+ let link = document.createElement('a');
406
+ link.className = 'page-link';
407
+ link.setAttribute('href', '#');
408
+ link.innerText = label;
409
+ link.addEventListener('click', function (e) {
410
+ e.preventDefault();
411
+ loadTag(tag_path, page_nb);
412
+ });
413
+ item.appendChild(link);
414
+ }
415
+ return item;
416
+ }
417
+
418
+ if (result.pages > 1 || page !== 1) {
419
+ if (result.pages === undefined && page !== 1) {
420
+ result.pages = 1;
421
+ }
422
+ let pagination = document.createElement('nav');
423
+ pagination.setAttribute('aria-label', "Page navigation");
424
+ let pagination_ul = document.createElement('ul');
425
+ pagination_ul.className = 'pagination justify-content-center';
426
+ const min_page = Math.max(2, page - 2);
427
+ const max_page = Math.min(result.pages - 1, page + 2);
428
+
429
+ // Previous button
430
+ pagination_ul.appendChild(makePageLink(page - 1, "Previous", false, page > 1));
431
+ // Page 1
432
+ pagination_ul.appendChild(makePageLink(1, 1, 1 === page, 1 !== page));
433
+ // "..." between 1 and other pages, if appropriate
434
+ if (min_page > 2) {
435
+ pagination_ul.appendChild(makePageLink(page, "...", false, false));
436
+ }
437
+ // Other pages
438
+ for (let i = min_page; i <= max_page; ++i) {
439
+ pagination_ul.appendChild(makePageLink(i, i, i === page, i !== page));
440
+ }
441
+ // "..." between other pages and last page, if appropriate
442
+ if (max_page < result.pages - 1) {
443
+ pagination_ul.appendChild(makePageLink(page, "...", false, false));
444
+ }
445
+ // Last page
446
+ pagination_ul.appendChild(makePageLink(result.pages, result.pages, result.pages === page, result.pages !== page));
447
+ // Next button
448
+ pagination_ul.appendChild(makePageLink(page + 1, "Next", false, page < result.pages));
449
+
450
+ pagination.appendChild(pagination_ul);
451
+ document_contents.appendChild(pagination);
452
+ }
453
+
454
+ // Update export button
455
+ const items = document.querySelectorAll('#document-export-dropdown-menu a');
456
+ for (const item of items) {
457
+ const ext = item.dataset.extension;
458
+ if (item.dataset.highlights === 'false') {
459
+ item.style.display = 'none';
460
+ } else {
461
+ item.setAttribute('href', base_path + '/project/' + project_id + '/export/highlights/' + encodeURIComponent(tag_path) + '.' + ext);
462
+ item.style.display = '';
463
+ }
464
+ }
465
+
466
+ toggleToolbarActions(true)
467
+
468
+ // Scroll up
469
+ window.setTimeout(function () {
470
+ window.scrollTo(0, 0);
471
+ }, 0);
472
+ })
473
+ .catch(function (error) {
474
+ console.error("Failed to load tag highlights", error);
475
+ log("Failed to load tag highlights", 'error', {error});
476
+ const loadTagHighlightsErrorMessage = gettext("Error loading tag highlights!");
477
+ showAlert(`${loadTagHighlightsErrorMessage} \n \n`, 'error', {error})
478
+ })
479
+ .then(function (result) {
480
+ if (minSpinnerMs === undefined || spinnerShownAt === undefined) {
481
+ hideSpinner();
482
+ return;
483
+ }
484
+ const remaining = minSpinnerMs - (Date.now() - spinnerShownAt);
485
+ setTimeout(function () {
486
+ if (useProgressBar) {
487
+ // Reset the inline styles so a later real upload's spinner
488
+ // circle and progress bar behave as before.
489
+ document.getElementById('spinner').style.display = '';
490
+ const bar = document.querySelector('#progress .progress-bar');
491
+ bar.style.transition = '';
492
+ bar.style.width = '0%';
493
+ }
494
+ hideSpinner();
495
+ }, Math.max(0, remaining));
496
+ });
497
+ }