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,124 @@
1
+ $(document).ready(function() {
2
+ const regEscape = v => v.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
3
+ const $formSearch = $("#form-search-documents");
4
+ const $inputSearch = $("#input-search-documents");
5
+ const $divSearchResults = $("#ul-search-results");
6
+ const minimumCharacters = 3;
7
+ let searchTimeout = null;
8
+
9
+
10
+ $inputSearch.keyup(function(){
11
+ clearTimeout(searchTimeout);
12
+ searchTimeout = setTimeout(function(){
13
+ doSearch();
14
+ }, 500);
15
+ });
16
+
17
+ $formSearch.submit(function(e){
18
+ e.preventDefault();
19
+ clearTimeout(searchTimeout);
20
+ doSearch();
21
+
22
+ return false;
23
+ });
24
+
25
+ $('button.page-selector').click(function(e) {
26
+ e.stopPropagation();
27
+ e.preventDefault();
28
+
29
+ doSearch($(this).data('page'));
30
+
31
+ return false;
32
+ });
33
+
34
+ function showSearchSpinner() {
35
+ $formSearch.addClass('searching');
36
+ }
37
+
38
+ function hideSearchSpinner() {
39
+ $formSearch.removeClass('searching');
40
+ }
41
+
42
+ function clearSearchContent() {
43
+ $divSearchResults.html('');
44
+ }
45
+
46
+ function doSearch(page=1) {
47
+ const searchText = $inputSearch.val().toLowerCase(),
48
+ previousPage = page - 1,
49
+ nextPage = page + 1;
50
+ log(`Search in documents '${searchText}'`);
51
+ $divSearchResults.html('');
52
+ $('button.page-selector').hide();
53
+ if(searchText.length < minimumCharacters) {
54
+ log(`Not enough characters to search documents, ${minimumCharacters} or more characters are required`)
55
+ return;
56
+ }
57
+
58
+ if(searchText !== '') {
59
+ showSearchSpinner();
60
+
61
+ getJSON(
62
+ `/api/project/${project_id}/search/${encodeURIComponent(searchText)}?page=${page}`
63
+ )
64
+ .then(function (result) {
65
+ $("<li/>", {
66
+ 'class': "dropdown-item disabled sticky first",
67
+ 'html': gettext("Found in %(nb)s document(s)", {nb: result['nb_results']}) + `<span class='pagination'>${page} / ${result['pages']}</span>`
68
+ }).appendTo($divSearchResults);
69
+ if(result['nb_results'] > 0) {
70
+ $('#btn-search-previous-page').data('page', previousPage);
71
+ $('#btn-search-next-page').data('page', nextPage);
72
+ if(previousPage <= 0) {
73
+ $('#btn-search-previous-page').prop('disabled', true);
74
+ } else {
75
+ $('#btn-search-previous-page').prop('disabled', false);
76
+ }
77
+ log(`nextpage: ${nextPage} >= ${result['pages']}`)
78
+ if(nextPage > result['pages']) {
79
+ $('#btn-search-next-page').prop('disabled', true);
80
+ } else {
81
+ $('#btn-search-next-page').prop('disabled', false);
82
+ }
83
+ $('button.page-selector').show();
84
+ } else {
85
+ $('button.page-selector').hide();
86
+ }
87
+ for(let k in result['documents']) {
88
+ let d = result['documents'][k];
89
+ // Add document title
90
+ $("<li/>", {
91
+ 'class': "dropdown-item disabled sticky",
92
+ 'text': `${d['name']} (${d['occurences']} occurrences)`
93
+ }).appendTo($divSearchResults);
94
+
95
+ // Find results and context
96
+ let results = d['contents'].split(new RegExp(regEscape(result['search_text']), "ig"));
97
+ for(let i=0; i<results.length-1; i++) {
98
+ let start = results[i].slice(-40),
99
+ end = results[i+1].slice(0, 40),
100
+ a = document.createElement('a');
101
+ a.className = "dropdown-item";
102
+ a.innerHTML = `...${start}<span class="search-item">${result['search_text']}</span>${end}...`
103
+ linkDocument(a, d['id'], null, `${result['search_text']}/${i}`);
104
+ $("<li/>").append(a).appendTo($divSearchResults);
105
+ }
106
+ }
107
+
108
+ // Force show
109
+ if(!$("#dropdown-trigger-search").hasClass('show')) {
110
+ $("#dropdown-trigger-search").dropdown('toggle');
111
+ }
112
+ })
113
+ .catch(function (error) {
114
+ log("Failed to search in documents", 'error', {error});
115
+ showAlert(gettext("Error searching documents!"), 'error', {error})
116
+ })
117
+ .then(function (result) {
118
+ hideSearchSpinner();
119
+ });
120
+ } else {
121
+ clearSearchContent()
122
+ }
123
+ }
124
+ });
@@ -0,0 +1,133 @@
1
+
2
+ function addContextMenu() {
3
+ $.contextMenu({
4
+ selector: '.tag-context-menu',
5
+ build: function ($trigger) {
6
+ const nodeId = $trigger.attr('id');
7
+ const node = getNode(treeview_tags, nodeId);
8
+
9
+ let items = generateContextMenuItems(node);
10
+
11
+ return {
12
+ callback: (key, options) => contextMenuAction(key, options.$trigger.attr("id")),
13
+ items
14
+ };
15
+ }
16
+ });
17
+ }
18
+
19
+ function generateContextMenuItems(node) {
20
+
21
+ const tagId = getTagIdFromTreeviewNode(treeview_tags, node.nodeId);
22
+ const tag = findTag(tagId);
23
+
24
+ const hasChildren = tag.children.length > 0;
25
+ const hasParent = tag.parent > 0;
26
+ const isExpanded = node.state.expanded;
27
+ const isSelected = node.state.selected;
28
+
29
+ const selectedNodes = getAllSelectedNodes(treeview_tags).length > 0;
30
+ const hasSelectedChildren = checkNodeHasSelectedChildren(node);
31
+ const hasUnselectedChildren = checkNodeHasUnselectedChildren(node);
32
+
33
+ return {
34
+ "toggleSelectAll": {
35
+ name: gettext('selectAll'),
36
+ icon: 'fas fa-check-square',
37
+ visible: hasUnselectedChildren && hasChildren,
38
+ callback: (key, options) => contextMenuAction('selectAll', node.nodeId)
39
+ },
40
+ "toggleDeSelectAll": {
41
+ name: gettext('unselectAll'),
42
+ icon: 'fas fa-square',
43
+ visible: hasChildren && hasSelectedChildren,
44
+ callback: (key, options) => contextMenuAction('unselectAll', node.nodeId)
45
+ },
46
+ "exploreCode": {
47
+ name: gettext('Explore codes'),
48
+ icon: 'fas fa-search',
49
+ callback: (key, options) => contextMenuAction('exploreCode', tagId)
50
+ },
51
+ "toggleSelect": {
52
+ name: gettext('Select With Condition'),
53
+ icon: 'fas fa-filter',
54
+ visible: false,
55
+ items: {
56
+ "andSelect": {
57
+ name: gettext('AND'),
58
+ icon: 'fas fa-plus',
59
+ callback: (key, options) => contextMenuAction('selectAnd', node.nodeId)
60
+ },
61
+ "orSelect": {
62
+ name: gettext('OR'),
63
+ icon: 'fas fa-exchange-alt',
64
+ callback: (key, options) => contextMenuAction('selectOr', node.nodeId)
65
+ },
66
+ "notSelect": {
67
+ name: gettext('NOT'),
68
+ icon: 'fas fa-ban',
69
+ callback: (key, options) => contextMenuAction('selectNot', node.nodeId)
70
+ }
71
+ }
72
+ },
73
+ "toggleExpand": {
74
+ name: isExpanded ? gettext('collapse') : gettext('expand'),
75
+ icon: isExpanded ? 'fas fa-chevron-up' : 'fas fa-chevron-down',
76
+ visible: hasChildren,
77
+ callback: (key, options) => contextMenuAction(isExpanded ? 'collapse' : 'expand', node.nodeId)
78
+ },
79
+ "addChild": {
80
+ name: gettext('Add child'),
81
+ icon: "fas fa-plus-circle",
82
+ callback: (key, options) => contextMenuAction('addChild', tagId)
83
+ },
84
+ "edit": {
85
+ name: gettext('edit'),
86
+ icon: "fas fa-pen",
87
+ callback: (key, options) => contextMenuAction('edit', tagId)
88
+ },
89
+ "merge": {
90
+ name: gettext('merge'),
91
+ icon: "fas fa-copy",
92
+ callback: (key, options) => contextMenuAction('merge', tagId)
93
+ },
94
+ "deleteParent": {
95
+ name: gettext('deleteParent'),
96
+ icon: "fas fa-times-circle",
97
+ visible: hasParent,
98
+ callback: (key, options) => contextMenuAction('deleteParent', tagId)
99
+ },
100
+ "delete": {
101
+ name: gettext('delete'),
102
+ icon: "fas fa-trash",
103
+ callback: (key, options) => contextMenuAction('delete', tagId)
104
+ }
105
+ };
106
+ }
107
+
108
+ function contextMenuAction(action, param) {
109
+ const actionFunction = actions[action];
110
+
111
+ if (actionFunction) {
112
+ actionFunction(param);
113
+ } else {
114
+ log('No action found for:', 'warn', {action});
115
+ }
116
+ }
117
+
118
+ const actions = {
119
+ selectAll: nodeId => selectNodeAndChildren(treeview_tags, nodeId),
120
+ unselectAll: nodeId => unselectNodeAndChildren(treeview_tags, nodeId),
121
+ selectAnd: nodeId => selectWithCondition(treeview_tags, nodeId, 'and'),
122
+ selectOr: nodeId => selectWithCondition(treeview_tags, nodeId, 'or'),
123
+ selectNot: nodeId => selectWithCondition(treeview_tags, nodeId, 'not'),
124
+ exploreCode: tagId => openExploreCodeFromContextMenu(tagId),
125
+ expand: nodeId => expandNode(treeview_tags, nodeId),
126
+ collapse: nodeId => collapseNode(treeview_tags, nodeId),
127
+ unselect: nodeId => unselectNode(treeview_tags, nodeId),
128
+ edit: tagId => editTag(tagId),
129
+ addChild: tagId => createTag(false, current_selection, tagId),
130
+ merge: tagId => mergeTag(tagId),
131
+ delete: tagId => deleteTag(tagId),
132
+ deleteParent: tagId => removeTagParent(tagId)
133
+ };
@@ -0,0 +1,400 @@
1
+ const treeview_tags_explore = $('#treeview-tags-explore');
2
+
3
+ let exploreSelectedTags = []; // [{id, path, zone}] where zone is 'must' | 'can' | 'exclude'
4
+ let exploreActiveZone = null;
5
+ let appliedExploreTags = []; // last query actually applied, kept after the modal closes so it can be re-edited
6
+
7
+ function initTagsExploreTreeview(filterTags = null) {
8
+
9
+ if (filterTags == null) {
10
+ filterTags = tags;
11
+ }
12
+
13
+ const treeData = convertToTreeNode(filterTags, true);
14
+
15
+ const customNodeOptions = customNodeRow(false, true, false, 'tag-node-explore-');
16
+
17
+ const options = {
18
+ data: treeData,
19
+ ...customNodeOptions,
20
+ showBorder: true,
21
+ levels: CUSTOM_LEVEL_OPTION,
22
+ multiSelect: true,
23
+ };
24
+
25
+ treeview_tags_explore.treeview(options);
26
+
27
+ treeview_tags_explore.off('nodeSelected').on('nodeSelected', function (event, node) {
28
+ if (!exploreSelectedTags.some(item => item.id === node.id)) {
29
+ exploreSelectedTags.push({
30
+ id: node.id,
31
+ path: node.text,
32
+ zone: exploreActiveZone
33
+ });
34
+ renderExploreZones();
35
+ }
36
+
37
+ // Only clear on a genuine user click, not the programmatic re-selection
38
+ // that happens when the tree is rebuilt (e.g. restoring selections).
39
+ if (!isProgrammaticSelection) {
40
+ clearExploreSearchFilter();
41
+ }
42
+ });
43
+
44
+ treeview_tags_explore.off('nodeUnselected').on('nodeUnselected', function (event, node) {
45
+ // Clicking a code that's already picked but for a different zone
46
+ // means "move it here", not "drop it": update its zone and put the
47
+ // node back in the tree so it stays checked.
48
+ const existing = exploreSelectedTags.find(item => item.id === node.id);
49
+ if (
50
+ !isProgrammaticSelection
51
+ && existing
52
+ && exploreActiveZone
53
+ && existing.zone !== exploreActiveZone
54
+ ) {
55
+ existing.zone = exploreActiveZone;
56
+ renderExploreZones();
57
+ const nodeId = getNodeIdFromTreeviewByTagId(
58
+ treeview_tags_explore, node.id,
59
+ );
60
+ if (nodeId !== null) {
61
+ selectNode(treeview_tags_explore, nodeId);
62
+ }
63
+ return;
64
+ }
65
+
66
+ exploreSelectedTags = exploreSelectedTags.filter(item => item.id !== node.id);
67
+ renderExploreZones();
68
+
69
+ // Same as nodeSelected: only clear on a genuine user click, not a
70
+ // programmatic unselection.
71
+ if (!isProgrammaticSelection) {
72
+ clearExploreSearchFilter();
73
+ }
74
+ });
75
+
76
+ restoreExploreSelectedNodes();
77
+ }
78
+
79
+ function clearExploreSearchFilter() {
80
+ const searchInput = $('#input-search-tags-explore');
81
+ if (searchInput.val() === '') return;
82
+ searchInput.val('');
83
+ $('#alert-tag-explore-treeview').addClass('d-none');
84
+ // Deferred so it runs after the treeview's own pending render (see treeview.js's similar setTimeout uses).
85
+ setTimeout(() => initTagsExploreTreeview(), 0);
86
+ }
87
+
88
+ function restoreExploreSelectedNodes() {
89
+ exploreSelectedTags.forEach(item => {
90
+ const nodeId = getNodeIdFromTreeviewByTagId(treeview_tags_explore, item.id);
91
+ if (nodeId !== null) {
92
+ selectNode(treeview_tags_explore, nodeId);
93
+ }
94
+ });
95
+ }
96
+
97
+ function generateExploreChipHtml(tag) {
98
+ const abbreviation = abbreviateTag(tag.path);
99
+ return `<span class="tag-badge me-2 my-1" title="${tag.path}" style="background-color: ${tag.color};">
100
+ ${abbreviation}
101
+ <button type="button" onclick="event.stopPropagation(); removeExploreChip(${tag.id})" class="tag-badge-button">x</button>
102
+ </span>`;
103
+ }
104
+
105
+ function renderExploreZones() {
106
+ const zones = {
107
+ must: $('#explore-zone-must-chips'),
108
+ can: $('#explore-zone-can-chips'),
109
+ exclude: $('#explore-zone-exclude-chips')
110
+ };
111
+
112
+ Object.keys(zones).forEach(zoneKey => {
113
+ const zoneTags = exploreSelectedTags.filter(item => item.zone === zoneKey);
114
+ if (zoneTags.length === 0) {
115
+ const noCodesYetLabel = gettext('No codes yet');
116
+ zones[zoneKey].html(`<span class="text-muted small fst-italic">${noCodesYetLabel}</span>`);
117
+ } else {
118
+ let content = '';
119
+ zoneTags.forEach(item => {
120
+ const tag = findTag(item.id);
121
+ if (tag) {
122
+ content += generateExploreChipHtml(tag);
123
+ }
124
+ });
125
+ zones[zoneKey].html(content);
126
+ }
127
+ });
128
+
129
+ const hasQuery = exploreSelectedTags.some(item => item.zone === 'must' || item.zone === 'can');
130
+ $('#explore-codes-apply').prop('disabled', !hasQuery);
131
+ }
132
+
133
+ function removeExploreChip(tagId) {
134
+ const nodeId = getNodeIdFromTreeviewByTagId(treeview_tags_explore, tagId);
135
+ if (nodeId !== null) {
136
+ unselectNode(treeview_tags_explore, nodeId);
137
+ } else {
138
+ exploreSelectedTags = exploreSelectedTags.filter(item => item.id !== tagId);
139
+ renderExploreZones();
140
+ }
141
+ }
142
+
143
+ // Toggle which zone newly-clicked codes are added to. Reveals the code picker
144
+ // (search box + codebook tree) in place; clicking the same zone's + again
145
+ // closes it, clicking a different zone's + retargets it and flashes the
146
+ // picker so the change in destination is noticeable.
147
+ function toggleExploreZone(zone) {
148
+ const picker = $('#explore-code-picker');
149
+ const isOpen = !picker.hasClass('d-none');
150
+
151
+ if (isOpen && exploreActiveZone === zone) {
152
+ picker.addClass('d-none');
153
+ exploreActiveZone = null;
154
+ updateActiveZoneStyling();
155
+ return;
156
+ }
157
+
158
+ const zoneChanged = isOpen && exploreActiveZone !== zone;
159
+ exploreActiveZone = zone;
160
+ updateActiveZoneStyling();
161
+ picker.removeClass('d-none');
162
+
163
+ if (zoneChanged) {
164
+ flashExplorePicker();
165
+ }
166
+ }
167
+
168
+ function handleZoneBoxKeydown(event, zone) {
169
+ if (event.key === 'Enter' || event.key === ' ') {
170
+ event.preventDefault();
171
+ toggleExploreZone(zone);
172
+ }
173
+ }
174
+
175
+ function updateActiveZoneStyling() {
176
+ $('.explore-zone-box').removeClass('zone-active');
177
+ if (exploreActiveZone) {
178
+ $(`#explore-zone-${exploreActiveZone}-box`).addClass('zone-active');
179
+ }
180
+ }
181
+
182
+ function flashExplorePicker() {
183
+ const picker = document.getElementById('explore-code-picker');
184
+ picker.classList.remove('explore-picker-flash');
185
+ void picker.offsetWidth; // force reflow so the animation restarts on repeated clicks
186
+ picker.classList.add('explore-picker-flash');
187
+ }
188
+
189
+ function buildExploreQueryString() {
190
+ const must = exploreSelectedTags.filter(item => item.zone === 'must').map(item => item.path);
191
+ const can = exploreSelectedTags.filter(item => item.zone === 'can').map(item => item.path);
192
+ const exclude = exploreSelectedTags.filter(item => item.zone === 'exclude').map(item => item.path);
193
+
194
+ const mustGroup = must.join('∩');
195
+
196
+ let groups;
197
+ if (can.length > 0) {
198
+ groups = can.map(path => mustGroup ? `${mustGroup}∩${path}` : path);
199
+ } else {
200
+ groups = mustGroup ? [mustGroup] : [];
201
+ }
202
+
203
+ let query = groups.join('∪');
204
+ exclude.forEach(path => {
205
+ query += `¬${path}`;
206
+ });
207
+
208
+ return query;
209
+ }
210
+
211
+ function applyExploreQuery() {
212
+ const queryString = buildExploreQueryString();
213
+ if (!queryString) return;
214
+
215
+ const tagsSnapshot = exploreSelectedTags.slice();
216
+ appliedExploreTags = tagsSnapshot.map(item => ({...item}));
217
+
218
+ $('#explore-codes-modal').modal('hide');
219
+
220
+ const url = `${base_path}/project/${project_id}/highlights/${encodeURIComponent(queryString)}`;
221
+ window.history.pushState({tag_path: queryString}, `Tag ${queryString}`, url);
222
+ // Shows the progress bar animating over 600ms (vs. the brief spinner flash
223
+ // on a normal tag click), so applying a query reads as "computed", not a glitch.
224
+ loadTag(queryString, undefined, 600);
225
+
226
+ const tags_return = $('#tag-selection-return'),
227
+ tags_return_link = $('a', tags_return);
228
+ tags_return_link.unbind('click').hide();
229
+ tags_return_link.attr('href', url);
230
+ tags_return_link.bind('click', function (e) {
231
+ e.preventDefault();
232
+ if (window.history.state.tag_path === '') {
233
+ loadTag('');
234
+ selectAllTag();
235
+ } else {
236
+ window.history.back();
237
+ }
238
+ tags_return_link.hide();
239
+ });
240
+
241
+ showExploreQuerySummary(tagsSnapshot);
242
+ }
243
+
244
+ // Mirrors showTagSelection()'s sidebar query box, but grouped by zone since the
245
+ // explore query's AND/OR/NOT structure can't always be expressed as the linear
246
+ // chain that selectedTags/showTagSelection() understands.
247
+ function showExploreQuerySummary(tagsSnapshot) {
248
+ const criteriaArea = $('#tag-selection-criteria-area');
249
+
250
+ const zoneLabels = {
251
+ can: gettext('Can use'),
252
+ must: gettext('Must use'),
253
+ exclude: gettext('Exclude')
254
+ };
255
+
256
+ const editSelectionLabel = gettext('Edit selection');
257
+ const clearSelectionLabel = gettext('Clear selection');
258
+ let content = '<div class="d-flex justify-content-center gap-3 explore-query-actions">';
259
+ content += `<span id="edit-selection" onclick="editExploreQuery()" style="cursor:pointer;">` +
260
+ `<i class="fas fa-pen" aria-hidden="true" title="${editSelectionLabel}"></i></span>`;
261
+ content += `<span id="clear-selection" onclick="clearExploreQuerySelection()" style="cursor:pointer;">` +
262
+ `<i class="fa fa-times" aria-hidden="true" title="${clearSelectionLabel}"></i></span>`;
263
+ content += '</div>';
264
+
265
+ ['can', 'must', 'exclude'].forEach(zoneKey => {
266
+ const zoneTags = tagsSnapshot.filter(item => item.zone === zoneKey);
267
+ if (zoneTags.length === 0) return;
268
+
269
+ content += `<div class="mt-1"><strong style="font-size: 0.8rem;">${zoneLabels[zoneKey]}:</strong> `;
270
+ zoneTags.forEach(item => {
271
+ const tag = findTag(item.id);
272
+ if (tag) {
273
+ content += `<span title="${tag.path}" class="tag-badge me-1" style="background-color: ${tag.color};">${abbreviateTag(tag.path)}</span>`;
274
+ }
275
+ });
276
+ content += '</div>';
277
+ });
278
+
279
+ criteriaArea.html(content).show();
280
+ }
281
+
282
+ function clearExploreQuerySelection() {
283
+ appliedExploreTags = [];
284
+ const url = `${base_path}/project/${project_id}/highlights/`;
285
+ window.history.pushState({tag_path: ''}, 'Tag ', url);
286
+ $('#tag-selection-criteria-area').hide().html('');
287
+ hideTagReturnLink();
288
+ loadDocument(current_document);
289
+ }
290
+
291
+ function seedExploreModalAndOpen(tagsToSeed) {
292
+ exploreSelectedTags = tagsToSeed.map(item => ({...item}));
293
+ initTagsExploreTreeview();
294
+ renderExploreZones();
295
+ openExploreCodes();
296
+ }
297
+
298
+ // Reopen the modal with the currently applied query's zones populated, so it
299
+ // can be tweaked and re-applied instead of having to rebuild it from scratch.
300
+ function editExploreQuery() {
301
+ seedExploreModalAndOpen(appliedExploreTags);
302
+ }
303
+
304
+ // Right-click a code in the codebook tree -> "Explore codes": opens the modal
305
+ // with that code already added to "Can use", for people who reach conditional
306
+ // selection via the context menu rather than the toolbar button.
307
+ function openExploreCodeFromContextMenu(tagId) {
308
+ const tag = findTag(tagId);
309
+ if (!tag) return;
310
+ seedExploreModalAndOpen([{id: tag.id, path: tag.path, zone: 'can'}]);
311
+ }
312
+
313
+ function resetExploreModal() {
314
+ exploreSelectedTags = [];
315
+ deselectAllNodes(treeview_tags_explore);
316
+ exploreActiveZone = null;
317
+ updateActiveZoneStyling();
318
+ $('#explore-code-picker').addClass('d-none').removeClass('explore-picker-flash');
319
+ $('#input-search-tags-explore').val('');
320
+ $('#alert-tag-explore-treeview').addClass('d-none');
321
+ initTagsExploreTreeview();
322
+ renderExploreZones();
323
+ }
324
+
325
+ function openExploreCodes() {
326
+ const modal = document.getElementById('explore-codes-modal');
327
+ $(modal).modal('show').drags({handle: '.modal-header'});
328
+ }
329
+
330
+ function parseExploreQueryString(queryString) {
331
+ if (!queryString) return [];
332
+
333
+ const parts = queryString.split('¬');
334
+ const head = parts[0];
335
+ const excludePaths = parts.slice(1).filter(Boolean);
336
+ const groups = head
337
+ ? head.split('∪').map(g => g.split('∩').filter(Boolean))
338
+ : [];
339
+
340
+ let mustPaths = [];
341
+ let canPaths = [];
342
+ if (groups.length === 1) {
343
+ mustPaths = groups[0];
344
+ } else if (groups.length > 1) {
345
+ mustPaths = groups[0].filter(p => groups.every(g => g.includes(p)));
346
+ const seen = new Set();
347
+ groups.forEach(g => g.forEach(p => {
348
+ if (!mustPaths.includes(p) && !seen.has(p)) {
349
+ seen.add(p);
350
+ canPaths.push(p);
351
+ }
352
+ }));
353
+ }
354
+
355
+ const toTag = (path, zone) => {
356
+ const tag = findTag(path);
357
+ return tag ? {id: tag.id, path: tag.path, zone} : null;
358
+ };
359
+ return [
360
+ ...mustPaths.map(p => toTag(p, 'must')),
361
+ ...canPaths.map(p => toTag(p, 'can')),
362
+ ...excludePaths.map(p => toTag(p, 'exclude')),
363
+ ].filter(Boolean);
364
+ }
365
+
366
+ function restoreExploreQueryFromUrl() {
367
+ const re = new RegExp(`^${escapeRegExp(base_path)}/project/[0-9]+/highlights/([^/]+)`);
368
+ const m = window.location.pathname.match(re);
369
+ if (!m) return;
370
+ const queryString = decodeURIComponent(m[1]);
371
+ const tags = parseExploreQueryString(queryString);
372
+ if (!tags.length) return;
373
+ appliedExploreTags = tags;
374
+ showExploreQuerySummary(tags);
375
+ }
376
+
377
+ $(document).ready(function () {
378
+ initTagsExploreTreeview();
379
+ renderExploreZones();
380
+ restoreExploreQueryFromUrl();
381
+
382
+ document.getElementById('input-search-tags-explore').addEventListener('input', function () {
383
+ const filterText = $(this).val().toLowerCase();
384
+ const filteredTags = handleSearch(filterText);
385
+ initTagsExploreTreeview(filteredTags);
386
+
387
+ const alert_explore_treeview = $('#alert-tag-explore-treeview');
388
+ if (filteredTags.length === 0) {
389
+ alert_explore_treeview.removeClass('d-none').html(`<strong>${TAG_NOT_FOUND_MESSAGE}</strong>`);
390
+ } else {
391
+ alert_explore_treeview.addClass('d-none');
392
+ }
393
+ });
394
+
395
+ document.getElementById('explore-codes-apply').addEventListener('click', applyExploreQuery);
396
+
397
+ $('#explore-codes-modal').on('hidden.bs.modal', resetExploreModal);
398
+
399
+ enableTypeToFocusSearch('#explore-codes-modal', '#input-search-tags-explore');
400
+ });