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,1790 @@
1
+ /*
2
+ * Table of contents
3
+ *
4
+ * - Utilities
5
+ * - Selection stuff
6
+ * - Project metadata
7
+ * - Documents list
8
+ * - Add document
9
+ * - Tags list
10
+ * - Highlights
11
+ * - Add highlight
12
+ * - Members
13
+ * - Load contents
14
+ * - Long polling
15
+ */
16
+
17
+
18
+ /*
19
+ * Project metadata
20
+ */
21
+
22
+ const project_name_input = document.getElementById('project-name');
23
+ let project_name = project_name_input.value;
24
+
25
+ const project_description_input = document.getElementById('project-description');
26
+ let project_description = project_description_input.value;
27
+ let upload_canceled = false;
28
+
29
+ function openSidebar() {
30
+
31
+ // Desktop behavior
32
+ if (window.innerWidth > 768) {
33
+ document.getElementById("project-sidebar").style.width = "600px";
34
+ } else {
35
+ document.getElementById("project-sidebar").style.width = "100%";
36
+ }
37
+ showMembers();
38
+ }
39
+
40
+ function closeSideBar() {
41
+ document.getElementById("project-sidebar").style.width = "0";
42
+ }
43
+
44
+ document.getElementById('project-sidebar-open').addEventListener('click', openSidebar);
45
+
46
+ document.addEventListener('click', function(event) {
47
+ const sidebar = document.getElementById('project-sidebar');
48
+ const openButton = document.getElementById('project-sidebar-open');
49
+
50
+ if (sidebar && sidebar.style.width && sidebar.style.width !== "0" && sidebar.style.width !== "0px") {
51
+ // Skip targets detached by their own click handler — `contains` would falsely report "outside".
52
+ if (!document.body.contains(event.target)) {
53
+ return;
54
+ }
55
+ if (!sidebar.contains(event.target) && !openButton.contains(event.target)) {
56
+ closeSideBar();
57
+ }
58
+ }
59
+ });
60
+
61
+ function setProjectMetadata(metadata, form = true) {
62
+ if (project_name === metadata.project_name && project_description === metadata.description) {
63
+ return;
64
+ }
65
+ // Update globals
66
+ project_name = metadata.project_name;
67
+ project_description = metadata.description;
68
+ // Update form
69
+ if (form) {
70
+ project_name_input.value = project_name;
71
+ project_description_input.value = project_description;
72
+ }
73
+ // Update elements
74
+ const elems = document.getElementsByClassName('project-name');
75
+ for (let i = 0; i < elems.length; ++i) {
76
+ elems[i].textContent = project_name;
77
+ }
78
+ log("Project metadata updated");
79
+ }
80
+
81
+ function projectMetadataChanged() {
82
+ if (project_name_input.value !== project_name || project_description_input.value !== project_description) {
83
+ log("Posting project metadata update");
84
+ const meta = {
85
+ name: project_name_input.value, description: project_description_input.value
86
+ };
87
+ postJSON(`/api/project/${project_id}`, meta)
88
+ .then(function () {
89
+ setProjectMetadata({
90
+ project_name: meta.name,
91
+ description: meta.description,
92
+ }, false);
93
+ })
94
+ .catch(function (error) {
95
+ log("Failed to update project metadata:", 'error', {error});
96
+ const updateProjectMetadataErrorMessage = gettext("Couldn't update project metadata!");
97
+ showAlert(`${updateProjectMetadataErrorMessage} \n \n ${error}`, 'error');
98
+ project_name_input.value = project_name;
99
+ project_description_input.value = project_description;
100
+ });
101
+ }
102
+ }
103
+
104
+ document.getElementById('project-metadata-form').addEventListener('submit', function (e) {
105
+ e.preventDefault();
106
+ projectMetadataChanged();
107
+ });
108
+ project_name_input.addEventListener('blur', projectMetadataChanged);
109
+ project_description_input.addEventListener('blur', projectMetadataChanged);
110
+
111
+
112
+ /*
113
+ * Add document
114
+ */
115
+
116
+
117
+ const document_add_modal = document.getElementById('document-add-modal');
118
+
119
+ function createDocument() {
120
+ document.getElementById('document-add-form').reset();
121
+
122
+ cleanupDocumentDispatchInterface();
123
+
124
+ initDocumentCollectionHierarchySelect();
125
+
126
+ $(document_add_modal).modal('show').drags({handle: '.modal-header'});
127
+ customSelect.classList.remove("active");
128
+ }
129
+
130
+ function basename(filename) {
131
+ if (filename) {
132
+ const idx = Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\'));
133
+ if (idx > -1) {
134
+ filename = filename.substring(idx + 1);
135
+ }
136
+ }
137
+ return filename;
138
+ }
139
+
140
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
141
+
142
+ function doUpload(form_data, percent=0) {
143
+ return new Promise(function(resolve, reject) {
144
+ let xhr = new XMLHttpRequest(),
145
+ name = form_data.get('name');
146
+
147
+
148
+ xhr.responseType = 'json';
149
+ xhr.open('POST', base_path + '/api/project/' + project_id + '/document/new');
150
+ xhr.onload = function () {
151
+ if (xhr.status === 200) {
152
+ log("Document upload complete");
153
+ resolve({
154
+ 'doc_to_load': xhr.response.created,
155
+ 'percent': percent
156
+ })
157
+ } else {
158
+ const context = xhr.status;
159
+ log("Document upload failed: status", 'error', {context})
160
+ let error = null;
161
+ try {
162
+ error = `<p>(${name}) ${xhr.response.error}</p>`;
163
+ } catch (e) {
164
+ }
165
+ if (!error) {
166
+ error = `<p>(${name}) Status ${xhr.status}</p>`;
167
+ }
168
+ reject({
169
+ 'error_msg': error,
170
+ 'percent': percent
171
+ });
172
+ }
173
+ };
174
+ xhr.onerror = function (e) {
175
+ log("Document upload failed:", 'error', {e});
176
+ reject({
177
+ 'error_msg': `<p>${e}</p>`,
178
+ 'percent': percent
179
+ });
180
+ }
181
+
182
+ xhr.send(form_data);
183
+ });
184
+ }
185
+
186
+ async function uploadFiles(files) {
187
+
188
+ showSpinner(function(){ upload_canceled = true; }, true);
189
+ await delay(100); // Trick to refresh dom
190
+ let error_msg = '',
191
+ doc_to_load = null,
192
+ num_files = files.length,
193
+ file_number = 1;
194
+ upload_canceled = false;
195
+ for(let f in files) {
196
+ if(isNaN(f) || upload_canceled) {
197
+ continue;
198
+ }
199
+ log(files[f]);
200
+ let form_data = new FormData(),
201
+ name = files[f].name,
202
+ percent = Math.ceil(100*file_number/num_files);
203
+ name = name.substring(0, 50);
204
+
205
+ form_data.append('name', name);
206
+ form_data.append('description', document.getElementById('document-add-description').value);
207
+ form_data.append('file', files[f]);
208
+ form_data.append('_xsrf', getCookie('_xsrf'));
209
+
210
+ const fileIndex = parseInt(f);
211
+ const fileSelector = document.querySelector(`#document-files-list select[data-file-index="${fileIndex}"]:not(.text-direction-selector)`);
212
+ const textDirectionSelector = document.querySelector(`#document-files-list .text-direction-selector[data-file-index="${fileIndex}"]`);
213
+
214
+ // Use individual text direction if available, otherwise fall back to global
215
+ const textDirection = textDirectionSelector?.value || document.getElementById('document-add-form').elements['document-add-direction'].value;
216
+ form_data.append('text_direction', textDirection);
217
+
218
+ if (fileSelector) {
219
+ if (fileSelector.dataset.createNew === 'true') {
220
+ const newCollectionName = fileSelector.dataset.newCollectionName || `Collection for ${name}`;
221
+ form_data.append('create_collection_name', newCollectionName);
222
+ } else {
223
+ const selectedCollectionId = fileSelector.value;
224
+ if (selectedCollectionId) {
225
+ form_data.append('collection_ids', selectedCollectionId);
226
+ }
227
+ }
228
+ }
229
+
230
+ file_number++;
231
+
232
+ await doUpload(form_data, percent).then(
233
+ function(success) {
234
+ updateProgress(success.percent);
235
+ if(doc_to_load === null) {
236
+ doc_to_load = success.doc_to_load;
237
+ }
238
+ },
239
+ function(error) {
240
+ updateProgress(error.percent);
241
+ error_msg += error.error_msg;
242
+ }
243
+ );
244
+ }
245
+
246
+ if(error_msg !== '') {
247
+ const uploadFileErrorMessage = gettext("Error uploading file!");
248
+ showAlert(`${uploadFileErrorMessage} \n \n${error_msg}`, 'error')
249
+ }
250
+
251
+ // Load first uploaded document
252
+ if(doc_to_load !== null) {
253
+ $(document_add_modal).modal('hide');
254
+ document.getElementById('document-add-form').reset();
255
+
256
+ cleanupDocumentDispatchInterface();
257
+
258
+ const url = `${base_path}/project/${project_id}/document/${doc_to_load}`;
259
+ window.history.pushState({document_id: doc_to_load}, "Document " + doc_to_load, url);
260
+ updateDocumentsList();
261
+ loadDocument(doc_to_load);
262
+ }
263
+ hideSpinner();
264
+ }
265
+
266
+ document.getElementById('document-add-form').addEventListener('submit', function (e) {
267
+ e.preventDefault();
268
+ log("Uploading documents...");
269
+ uploadFiles(document.getElementById('document-add-file').files)
270
+ });
271
+
272
+
273
+ /*
274
+ * Change document
275
+ */
276
+
277
+ const document_change_modal = document.getElementById('document-change-modal');
278
+
279
+ function editDocument(doc_id) {
280
+ document.getElementById('document-change-form').reset();
281
+ document.getElementById('document-change-id').value = '' + doc_id;
282
+ document.getElementById('document-change-name').value = '' + documents['' + doc_id].name;
283
+ document.getElementById('document-change-description').value = '' + documents['' + doc_id].description;
284
+ document.getElementById('document-change-form').elements['document-change-direction'].value = documents['' + doc_id].text_direction;
285
+
286
+ initDocumentChangeCollectionHierarchySelect(true);
287
+
288
+ const docCollections = documents['' + doc_id].collections || [];
289
+ if (docCollections.length > 0) {
290
+ const firstCollectionId = docCollections[0];
291
+ document.getElementById('document-change-collection-id').value = firstCollectionId;
292
+ const collection = findCollection(firstCollectionId);
293
+ if (collection) {
294
+ document.getElementById('dropdownMenuButtonDocumentChangeCollection').innerText = collection.name;
295
+ }
296
+ } else {
297
+ document.getElementById('document-change-collection-id').value = '';
298
+ document.getElementById('dropdownMenuButtonDocumentChangeCollection').innerText = gettext('None');
299
+ }
300
+
301
+ $(document_change_modal).modal('show').drags({handle: '.modal-header'});
302
+ }
303
+
304
+ function doDeleteDocument(project_id, doc_id, successCallback=null, errorCallback=null) {
305
+ deleteURL(`/api/project/${project_id}/document/${doc_id}`)
306
+ .then(successCallback)
307
+ .catch(function (error) {
308
+ if(errorCallback === null) {
309
+ log("Failed to delete document", 'error', {error});
310
+ const deleteDocumentErrorMessage = gettext("Couldn't delete document!!");
311
+ showAlert(`${deleteDocumentErrorMessage} \n \n`, 'error', {error})
312
+ } else {
313
+ errorCallback(error)
314
+ }
315
+ });
316
+ }
317
+
318
+ function deleteDocument(doc_id) {
319
+ showConfirm(gettext("Are you sure you want to delete the document '%(doc)s'?", {doc: documents[doc_id].name}), function () {
320
+ log(`document ${doc_id} ...`);
321
+ doDeleteDocument(project_id, doc_id, function () {
322
+ log("Document deletion posted");
323
+ $(document_change_modal).modal('hide');
324
+ document.getElementById('document-change-form').reset();
325
+
326
+ if (document && document.collections) {
327
+ for (let collectionId of document.collections) {
328
+ const collection = findCollection(parseInt(collectionId));
329
+ if (collection && collection.document_ids) {
330
+ const index = collection.document_ids.indexOf(parseInt(doc_id));
331
+ if (index !== -1) {
332
+ collection.document_ids.splice(index, 1);
333
+ }
334
+ }
335
+ }
336
+ }
337
+
338
+ delete documents['' + doc_id];
339
+ updateDocumentsList();
340
+ initDocumentsTreeview();
341
+
342
+ if (current_document === doc_id) {
343
+ window.history.pushState({}, "Project", base_path + '/project/' + project_id);
344
+ loadDocument(null);
345
+ }
346
+ });
347
+ }, true);
348
+ }
349
+
350
+ document.getElementById('document-change-form').addEventListener('submit', function (e) {
351
+ e.preventDefault();
352
+ log("Changing document...");
353
+
354
+ const doc_id = document.getElementById('document-change-id').value
355
+
356
+ const updatedDocument = {
357
+ id: doc_id,
358
+ name: document.getElementById('document-change-name').value,
359
+ description: document.getElementById('document-change-description').value,
360
+ text_direction: document.getElementById('document-change-form').elements['document-change-direction'].value
361
+ };
362
+
363
+ if (!updatedDocument.name || updatedDocument.name.length === 0) {
364
+ showAlert(gettext("Document name cannot be empty"));
365
+ return;
366
+ }
367
+
368
+ const selectedCollectionId = document.getElementById('document-change-collection-id').value;
369
+ updatedDocument.collections = selectedCollectionId ? [selectedCollectionId] : [];
370
+
371
+ const callbackSuccess = (response) => {
372
+ log("Document update posted");
373
+ $(document_change_modal).modal('hide');
374
+ document.getElementById('document-change-form').reset();
375
+ loadDocument(current_document);
376
+ }
377
+
378
+ const callbackFail = (error) => {
379
+ log("Document upload failed:", 'error', {error});
380
+ const uploadFileErrorMessage = gettext("Error uploading file!");
381
+ showAlert(`${uploadFileErrorMessage} \n \n`, 'error', {error})
382
+ };
383
+
384
+ submitDocumentUpdate(updatedDocument, callbackSuccess, callbackFail)
385
+
386
+ });
387
+
388
+ document.getElementById('document-change-delete').addEventListener('click', function (e) {
389
+ e.preventDefault();
390
+ const doc_id = parseInt(document.getElementById('document-change-id').value);
391
+ deleteDocument(doc_id);
392
+ });
393
+
394
+
395
+ /*
396
+ * Tags list
397
+ */
398
+
399
+ let sortTags = ['path', 'asc'];
400
+ let compactLayout = typeof projectCompactLayout !== 'undefined' && projectCompactLayout;
401
+
402
+ function applyCompactLayout(enabled) {
403
+ document.querySelector('.bordered-container').classList.toggle('compact-layout', enabled);
404
+ updateTagsList();
405
+ }
406
+
407
+ function toggleCompactLayout() {
408
+ compactLayout = document.getElementById('compact-layout-toggle').checked;
409
+ applyCompactLayout(compactLayout);
410
+ // Fire and forget — a failed save just means the toggle won't stick past
411
+ // the next reload, which is harmless enough to skip a spinner over.
412
+ postJSON(
413
+ `/api/project/${project_id}/display`,
414
+ {compact_layout: compactLayout},
415
+ ).catch(function (error) {
416
+ console.warn('Failed to persist compact layout:', error);
417
+ });
418
+ }
419
+
420
+ $(document).ready(function () {
421
+ if (compactLayout) {
422
+ applyCompactLayout(true);
423
+ }
424
+ });
425
+
426
+ function sortTagsBy(field, dir) {
427
+ if (hideCodeCounts && field === 'count') {
428
+ return;
429
+ }
430
+ sortTags = [field, dir];
431
+ let options = document.querySelectorAll('#tag-menu-more-actions a.sort-option');
432
+ options.forEach(function (option) {
433
+ if (option.dataset.sortField === field && option.dataset.sortDir === dir) {
434
+ option.classList.add('active');
435
+ } else {
436
+ option.classList.remove('active');
437
+ }
438
+ });
439
+ updateTagsList();
440
+ }
441
+
442
+ function linkTag(elem, tag_path, selectAll = true) {
443
+ const url = `${base_path}/project/${project_id}/highlights/${encodeURIComponent(tag_path)}`;
444
+ elem.setAttribute('href', url);
445
+ elem.addEventListener('click', function (e) {
446
+ e.preventDefault();
447
+ window.history.pushState({tag_path: tag_path}, "Tag " + tag_path, url);
448
+ loadTag(tag_path);
449
+ selectAllTag();
450
+ });
451
+ }
452
+
453
+ linkTag(document.getElementById('load-all-tags'), '');
454
+
455
+ function highlightModalReset() {
456
+ document.getElementById('highlight-add-form').reset();
457
+ highlightSelectedTags.length = 0;
458
+ showHighlightTagSelection();
459
+ hideSearchErrorMessage();
460
+ savedSelections = [];
461
+ initTagsHighlightTreeview();
462
+ }
463
+
464
+
465
+ const tag_add_modal = document.getElementById('tag-add-modal');
466
+
467
+ $(tag_add_modal).on('shown.bs.modal', function () {
468
+ document.getElementById('tag-add-path').focus();
469
+ });
470
+ let createTagFromHighlight;
471
+ function createTag(fromHighlight, selection, parent) {
472
+ document.getElementById('hlinfo').style.display = 'none';
473
+ createTagFromHighlight = fromHighlight;
474
+ document.getElementById('tag-add-form').reset();
475
+ document.getElementById('tag-add-id').value = '';
476
+ document.getElementById('tag-add-path').value = '';
477
+ document.getElementById('tag-add-color').value = '';
478
+ document.getElementById('tag-highlight-add-start').value = '';
479
+ document.getElementById('tag-highlight-add-end').value = '';
480
+ document.getElementById('tag-add-label-new').style.display = '';
481
+ document.getElementById('tag-add-label-change').style.display = 'none';
482
+ document.getElementById('tag-add-cancel').style.display = '';
483
+ document.getElementById('tag-add-delete').style.display = 'none';
484
+ document.getElementById('tag-add-merge').style.display = 'none';
485
+ if (fromHighlight) {
486
+ document.getElementById('tag-add-path').value = document.getElementById('input-search-tags-highlight').value;
487
+ }
488
+
489
+ const tagPreview = document.getElementById('tag-selection-preview');
490
+ if (tagPreview) {
491
+ const showPreview = !!(selection || (fromHighlight && current_selection_text));
492
+ tagPreview.textContent = formatSelectionPreview(current_selection_text);
493
+ tagPreview.style.display = showPreview && current_selection_text ? '' : 'none';
494
+ }
495
+
496
+ if(selection) {
497
+ document.getElementById('tag-highlight-add-start').value = selection[0];
498
+ document.getElementById('tag-highlight-add-end').value = selection[1];
499
+ }
500
+
501
+ if(parent) {
502
+ document.getElementById('tag-add-parent-id').value = parent;
503
+ document.getElementById('dropdownMenuButtonTag').innerText = findTag(parent).path
504
+ }
505
+
506
+ $(tag_add_modal).modal('show').drags({handle: '.modal-header'});
507
+ }
508
+
509
+ function editTag(tag_id) {
510
+ const tag = findTag(tag_id);
511
+ document.getElementById('tag-add-form').reset();
512
+ document.getElementById('tag-add-id').value = '' + tag_id;
513
+ if (tag.parent) {
514
+ document.getElementById('tag-add-parent-id').value = tag.parent;
515
+ document.getElementById('dropdownMenuButtonTag').innerText = findTag(tag.parent).path;
516
+ }
517
+ document.getElementById('tag-add-path').value = tag.path;
518
+ document.getElementById('tag-add-color').value = tag.color;
519
+ document.getElementById('tag-add-description').value = tag.description;
520
+ document.getElementById('tag-add-label-new').style.display = 'none';
521
+ document.getElementById('tag-add-label-change').style.display = '';
522
+ document.getElementById('tag-add-cancel').style.display = 'none';
523
+ document.getElementById('tag-add-delete').style.display = '';
524
+ document.getElementById('tag-add-merge').style.display = '';
525
+ $(tag_add_modal).modal('show').drags({handle: '.modal-header'});
526
+ }
527
+
528
+ // Save tag button
529
+ document.getElementById('tag-add-form').addEventListener('submit', function (e) {
530
+ e.preventDefault();
531
+
532
+ let tag_id = document.getElementById('tag-add-id').value
533
+ ? parseInt(document.getElementById('tag-add-id').value) : null;
534
+ let tag_path = document.getElementById('tag-add-path').value;
535
+ let tag_parent_id = document.getElementById('tag-add-parent-id').value
536
+ ? parseInt(document.getElementById('tag-add-parent-id').value) : null;
537
+
538
+ if (!tag_path) {
539
+ showAlert(gettext("Invalid tag name"));
540
+ return;
541
+ }
542
+
543
+ if (tag_id && tag_parent_id && tag_id === tag_parent_id) {
544
+ showAlert(gettext("Impossible to define the same tag as parent"), 'warning');
545
+ return;
546
+ }
547
+
548
+ const new_tag = {
549
+ id: tag_id,
550
+ path: tag_path,
551
+ description: document.getElementById('tag-add-description').value,
552
+ color: document.getElementById('tag-add-color').value,
553
+ parent_id: tag_parent_id
554
+ };
555
+
556
+ const callbackSuccess = (reply) => {
557
+
558
+ log("Tag posted");
559
+
560
+ last_added_tag = reply.id;
561
+
562
+ const selection = [
563
+ parseInt(document.getElementById('tag-highlight-add-start').value),
564
+ parseInt(document.getElementById('tag-highlight-add-end').value)
565
+ ];
566
+
567
+ // Check if either element of the selection array is NaN
568
+ const containsNaN = selection.some(isNaN);
569
+
570
+ // add highlight if it created from selection
571
+ if(!containsNaN && !tag_id) {
572
+ const highlight = {
573
+ id: undefined, selection, tags: [last_added_tag]
574
+ }
575
+
576
+ setTimeout(function() {
577
+ //call submit
578
+ submitHighLightCreation(highlight);
579
+ }, 60);
580
+ }
581
+
582
+
583
+ $(tag_add_modal).modal('hide');
584
+ document.getElementById('input-search-tags-highlight').value = '';
585
+ document.getElementById('dropdownMenuButtonTag').innerText = gettext('None');
586
+
587
+ };
588
+ const callbackFail = (error) => {
589
+ log("Failed to create tag", 'error', {error});
590
+ const createTagErrorMessage = gettext("Couldn't create tag!");
591
+ showAlert(`${createTagErrorMessage} \n \n`, 'error', {error})
592
+ };
593
+
594
+ // Call submitTagCreation with the new_tag object and callbacks
595
+ submitTagCreation(new_tag, callbackSuccess, callbackFail);
596
+
597
+ });
598
+
599
+ // Delete tag button
600
+ document.getElementById('tag-add-delete').addEventListener('click', function (e) {
601
+ let tag_id = document.getElementById('tag-add-id').value;
602
+ deleteTag(tag_id);
603
+ });
604
+
605
+ function deleteTag(tag_id) {
606
+ if (tag_id) {
607
+ const tag = findTag(tag_id)
608
+ showConfirm(gettext("Are you sure you want to delete the tag '%(tag)s'?", {tag: tag.path}), function () {
609
+ tag_id = parseInt(tag_id);
610
+ log(`Posting tag ${tag_id} deletion`);
611
+ const callbackSuccess = (result) => {
612
+ $(tag_add_modal).modal('hide');
613
+ document.getElementById('tag-add-form').reset();
614
+ };
615
+
616
+ const callbackFail = (error) => {
617
+ log("Failed to delete tag", 'error', {error});
618
+ const deleteTagErrorMessage = gettext("Couldn't delete tag!");
619
+ showAlert(`${deleteTagErrorMessage} \n \n`, 'error', {error})
620
+ };
621
+
622
+ submitTagDelete(tag_id, callbackSuccess, callbackFail);
623
+ }, true);
624
+ }
625
+ }
626
+
627
+
628
+
629
+ // Merge tags button in tag edit modal: shows merge modal
630
+ document.getElementById('tag-add-merge').addEventListener('click', function (e) {
631
+ e.preventDefault();
632
+
633
+ let tag_id = document.getElementById('tag-add-id').value;
634
+ if (!tag_id) return;
635
+ mergeTag(tag_id);
636
+ });
637
+
638
+ function mergeTag(tag_id) {
639
+
640
+ tag_id = parseInt(tag_id);
641
+ const tag = findTag(tag_id);
642
+
643
+ document.getElementById('tag-merge-form').reset();
644
+ document.getElementById('tag-merge-src-id').value = '' + tag_id;
645
+ document.getElementById('tag-merge-src-name').value = tag.path;
646
+
647
+ document.getElementById('tag-merge-warning').classList.add('d-none');
648
+ document.getElementById('tag-merge-warning-text').textContent = '';
649
+
650
+ $(document.getElementById('tag-merge-modal')).modal('show');
651
+ }
652
+
653
+ function updateMergeWarning() {
654
+ const warningDiv = document.getElementById('tag-merge-warning');
655
+ const warningText = document.getElementById('tag-merge-warning-text');
656
+
657
+ const srcId = document.getElementById('tag-merge-src-id').value;
658
+ const destId = document.getElementById('tag-merge-dest').value;
659
+
660
+ if (!srcId || !destId) {
661
+ warningDiv.classList.add('d-none');
662
+ return;
663
+ }
664
+
665
+ const srcTag = findTag(parseInt(srcId));
666
+ const destTag = findTag(parseInt(destId));
667
+
668
+ if (srcTag && srcTag.children && srcTag.children.length > 0 && destTag) {
669
+ const childCount = srcTag.children.length;
670
+ warningText.textContent = gettext("This tag has %(count)s child tag(s) that will be moved to \"%(dest)s\".", {count: childCount, dest: destTag.path});
671
+ warningDiv.classList.remove('d-none');
672
+ } else {
673
+ warningDiv.classList.add('d-none');
674
+ }
675
+ }
676
+
677
+ const mergeSelectDropdown = document.getElementById('tag-hierarchy-merge-select');
678
+ if (mergeSelectDropdown) {
679
+ mergeSelectDropdown.addEventListener('click', function(e) {
680
+ if (e.target.classList.contains('dropdown-item')) {
681
+ setTimeout(updateMergeWarning, 50);
682
+ }
683
+ });
684
+ }
685
+
686
+ // Merge modal submit button
687
+ document.getElementById('tag-merge-form').addEventListener('submit', function (e) {
688
+ e.preventDefault();
689
+
690
+ let tag_src = document.getElementById('tag-merge-src-id').value;
691
+ if (!tag_src) return;
692
+ tag_src = parseInt(tag_src);
693
+
694
+ let tag_dest = document.getElementById('tag-merge-dest').value;
695
+ if (!tag_dest) return;
696
+ tag_dest = parseInt(tag_dest);
697
+
698
+ if (tag_src === tag_dest) {
699
+ showAlert(gettext("Couldn't merge tags!"))
700
+ return
701
+ }
702
+
703
+ const callbackSuccess = (result) => {
704
+ log("Tag merge posted");
705
+ $(document.getElementById('tag-merge-modal')).modal('hide');
706
+ };
707
+
708
+ const callbackFail = (error) => {
709
+ log("Failed to merge tags", 'error', {error});
710
+ showAlert(`${gettext("Couldn't merge tags!")} \n \n`, 'error', {error})
711
+ };
712
+
713
+ submitTagMerge(tag_src, tag_dest, callbackSuccess, callbackFail);
714
+ });
715
+
716
+
717
+ /*
718
+ * Add highlight
719
+ */
720
+
721
+ const highlight_add_modal = document.getElementById('highlight-add-modal');
722
+
723
+ // Updates current_selection and visibility of the controls
724
+ function selectionChanged() {
725
+ current_selection = describeSelection();
726
+ if (current_selection) {
727
+ current_selection_text = window.getSelection().toString();
728
+ }
729
+ log('current selection offset : ' + current_selection);
730
+ let hlinfo = document.getElementById('hlinfo');
731
+ if (current_selection !== null) {
732
+ const current_range = window.getSelection().getRangeAt(0);
733
+ if (current_range.toString().match(onlyWhitespace)) {
734
+ hlinfo.style.display = 'none';
735
+ } else if (current_range.endOffset > 0) {
736
+ let last_char_range = document.createRange();
737
+ last_char_range.setStart(current_range.endContainer, current_range.endOffset - 1);
738
+ last_char_range.setEnd(current_range.endContainer, current_range.endOffset);
739
+ let rect = last_char_range.getClientRects().item(0);
740
+ let scrollPos = getScrollPos();
741
+ hlinfo.style.left = ((rect.x || rect.left) + rect.width) + 'px';
742
+ hlinfo.style.top = ((rect.y || rect.top) + rect.height + scrollPos.y + 20) + 'px';
743
+ hlinfo.style.display = 'block';
744
+ } else {
745
+ // We are in a weird situation where the end of the selection is in
746
+ // an empty node. We just don't move the popup, seems to work in practice
747
+ }
748
+ } else {
749
+ hlinfo.style.display = 'none';
750
+ }
751
+ }
752
+
753
+ document.addEventListener('selectionchange', selectionChanged);
754
+
755
+ function formatSelectionPreview(text, maxLen = 150) {
756
+ if (!text) return '';
757
+ const trimmed = text.trim();
758
+ const truncated = trimmed.length > maxLen ? trimmed.slice(0, maxLen) + ' [...]' : trimmed;
759
+ return '\u201C' + truncated + '\u201D';
760
+ }
761
+
762
+ function createHighlight(selection) {
763
+ document.getElementById('hlinfo').style.display = 'none';
764
+ document.getElementById('highlight-add-id').value = '';
765
+ document.getElementById('highlight-add-start').value = selection[0];
766
+ document.getElementById('highlight-add-end').value = selection[1];
767
+ const hlPreview = document.getElementById('highlight-selection-preview');
768
+ if (hlPreview) {
769
+ hlPreview.textContent = formatSelectionPreview(current_selection_text);
770
+ hlPreview.style.display = current_selection_text ? '' : 'none';
771
+ }
772
+ highlightModalReset();
773
+ $(highlight_add_modal).modal('show').drags({handle: '.modal-header'});
774
+ }
775
+
776
+ function editHighlight() {
777
+ const selection = describeSelection();
778
+ if(selection) {
779
+ createHighlight(selection);
780
+ return;
781
+ }
782
+ highlightModalReset();
783
+ const id = this.getAttribute('data-highlight-id');
784
+ document.getElementById('highlight-add-id').value = id;
785
+ document.getElementById('highlight-add-start').value = highlights[id].start_offset;
786
+ document.getElementById('highlight-add-end').value = highlights[id].end_offset;
787
+ const hlPreview = document.getElementById('highlight-selection-preview');
788
+ if (hlPreview) {
789
+ const _tmp = document.createElement('div');
790
+ _tmp.innerHTML = (highlights[id].snippet || '')
791
+ .replace(/<br\s*\/?>/gi, '\n')
792
+ .replace(/<\/p>|<\/div>|<\/h[1-6]>|<\/li>/gi, '\n');
793
+ const snippet = _tmp.textContent.trim().replace(/\n{3,}/g, '\n\n') || null;
794
+ hlPreview.textContent = formatSelectionPreview(snippet);
795
+ hlPreview.style.display = snippet ? '' : 'none';
796
+ }
797
+ const hl_tags = highlights['' + id].tags;
798
+ const allNodes = treeview_tags_highlight.treeview('getEnabled');
799
+ for (let i = 0; i < hl_tags.length; ++i) {
800
+ highlightSelectedTags.push(hl_tags[i]);
801
+ const node = allNodes.find((node) => node.id === hl_tags[i]);
802
+ treeview_tags_highlight.treeview('selectNode', node.nodeId);
803
+ }
804
+ showHighlightTagSelection();
805
+ $(highlight_add_modal).modal('show').drags({handle: '.modal-header'});
806
+ }
807
+
808
+ $('#highlight-add-form').off().on('submit', (e) => {
809
+
810
+ e.preventDefault();
811
+
812
+ const highlight_id = document.getElementById('highlight-add-id').value;
813
+ const selection = [parseInt(document.getElementById('highlight-add-start').value), parseInt(document.getElementById('highlight-add-end').value)];
814
+
815
+ const selectedTags = highlightSelectedTags;
816
+
817
+ const hl_tags = selectedTags;
818
+
819
+ if (hl_tags.length === 0) return;
820
+
821
+ const highlight = {
822
+ id: highlight_id, selection, tags: hl_tags
823
+ }
824
+
825
+ const callbackSuccess = () => {
826
+ log("Highlight posted");
827
+ $(highlight_add_modal).modal('hide');
828
+ highlightModalReset();
829
+ };
830
+ const callbackFail = (error) => {
831
+ log("Failed to create highlight", 'error', {error})
832
+ const createHighlightErrorMessage = gettext("Couldn't create highlight!");
833
+ showAlert(`${createHighlightErrorMessage} \n\n`,'error');
834
+ };
835
+
836
+ //call submit
837
+ submitHighLightCreation(highlight, callbackSuccess, callbackFail);
838
+ })
839
+
840
+ // Delete highlight button
841
+ document.getElementById('highlight-delete').addEventListener('click', function () {
842
+ let highlight_id = document.getElementById('highlight-add-id').value;
843
+ if (highlight_id) {
844
+ highlight_id = parseInt(highlight_id);
845
+ log(`Posting highlight ${highlight_id} deletion`);
846
+ deleteURL(`/api/project/${project_id}/document/${current_document}/highlight/${highlight_id}`)
847
+ .then(function () {
848
+ $(highlight_add_modal).modal('hide');
849
+ highlightModalReset();
850
+ })
851
+ .catch(function (error) {
852
+ log("Failed to delete highlight", 'error', {error});
853
+ const deleteHighlightErrorMessage = gettext("Couldn't delete highlight!");
854
+ showAlert(`${deleteHighlightErrorMessage} \n \n`, 'error', {error})
855
+ });
856
+ }
857
+ });
858
+
859
+
860
+ /*
861
+ * Members
862
+ */
863
+
864
+ function addMember(login, privileges) {
865
+ members[login] = {privileges: privileges};
866
+ }
867
+
868
+ function removeMember(login) {
869
+ delete members[login];
870
+ }
871
+
872
+ const members_modal = document.getElementById('members-modal');
873
+
874
+ function patchMember(login, payload) {
875
+ const body = {};
876
+ body[login] = payload;
877
+ showSpinner();
878
+ return patchJSON('/api/project/' + project_id + '/members', body)
879
+ .finally(hideSpinner);
880
+ }
881
+
882
+ function _memberRow(login, user, can_edit, is_self) {
883
+ // Check if this user is the only admin
884
+ const adminCount = Object.values(members).filter(m => m.privileges === 'ADMIN').length;
885
+ const is_only_admin = is_self && user.privileges === 'ADMIN' && adminCount === 1;
886
+
887
+ let leaveBtn = '';
888
+ if (is_self && !is_only_admin) {
889
+ leaveBtn = '<button type="button" style="height: 35px" class="btn btn-danger col-md-4 form-group">Leave project</button>';
890
+ } else if (!is_self) {
891
+ leaveBtn = '<button type="button" style="height: 35px" class="btn btn-danger col-md-4 form-group"' + (can_edit ? '' : ' disabled') + '>Remove collaborator</button>';
892
+ }
893
+
894
+ let elem = document.createElement('div');
895
+ elem.className = 'row members-item';
896
+ elem.innerHTML = '<div class="col-md-4 text-center">' +
897
+ ' <i class="fas fa-user fa-2x"></i>' +
898
+ ' <p class="members-item-login">' + login + '</p>' +
899
+ '</div>' +
900
+ '<div class="col-md-4 form-group">' +
901
+ ' <select class="form-control"' + (can_edit ? '' : ' disabled') + '>' +
902
+ ' <option value="ADMIN">' + gettext("Full permissions") + '</option>' +
903
+ ' <option value="MANAGE_DOCS">' + gettext("Can't change collaborators / delete project") + '</option>' +
904
+ ' <option value="TAG">' + gettext("View & make changes") + '</option>' +
905
+ ' <option value="READ">' + gettext("View only") + '</option>' +
906
+ ' </select>' +
907
+ '</div>' +
908
+ leaveBtn +
909
+ '<hr class="custom-hr" />';
910
+
911
+ [].forEach.call(elem.querySelectorAll('option'), function (e) {
912
+ if (e.value === user.privileges) {
913
+ e.selected = true;
914
+ }
915
+ });
916
+
917
+ const select = elem.querySelector('select');
918
+ if (select && can_edit) {
919
+ let previousValue = user.privileges;
920
+ select.addEventListener('change', function () {
921
+ const newPriv = select.value;
922
+ patchMember(login, {privileges: newPriv})
923
+ .then(function () {
924
+ if (members[login]) {
925
+ members[login].privileges = newPriv;
926
+ }
927
+ previousValue = newPriv;
928
+ })
929
+ .catch(function (error) {
930
+ log("Failed to update collaborator privileges", 'error', error);
931
+ showAlert(gettext("Couldn't update collaborator!"), 'error');
932
+ select.value = previousValue;
933
+ });
934
+ });
935
+ }
936
+
937
+ const btn = elem.querySelector('button');
938
+ if (btn) {
939
+ btn.addEventListener('click', function () {
940
+ patchMember(login, null)
941
+ .then(function () {
942
+ elem.parentNode.removeChild(elem);
943
+ delete members[login];
944
+ if (is_self) {
945
+ $(members_modal).modal('hide');
946
+ window.location.href = '/';
947
+ }
948
+ })
949
+ .catch(function (error) {
950
+ log("Failed to remove collaborator", 'error', error);
951
+ showAlert(gettext("Couldn't remove collaborator!"), 'error');
952
+ });
953
+ });
954
+ }
955
+
956
+ return elem;
957
+ }
958
+
959
+ function showMembers() {
960
+ document.getElementById('members-add').reset();
961
+
962
+ const can_edit = members[user_login].privileges === 'ADMIN';
963
+
964
+ if (can_edit) {
965
+ document.getElementById('members-add-fields').removeAttribute('disabled');
966
+ } else {
967
+ document.getElementById('members-add-fields').setAttribute('disabled', 1);
968
+ }
969
+
970
+ let entries = Object.entries(members);
971
+ sortByKey(entries, function (e) {
972
+ return e[0];
973
+ });
974
+ log("Members:", entries.map(function (e) {
975
+ return e[0] + " (" + e[1].privileges + ")";
976
+ })
977
+ .join(", "));
978
+
979
+ // Empty the list
980
+ let current_members = document.getElementById('members-current');
981
+ current_members.innerHTML = '';
982
+
983
+ // Fill it back up
984
+ for (let i = 0; i < entries.length; ++i) {
985
+ const login = entries[i][0];
986
+ const user = entries[i][1];
987
+ const elem = _memberRow(login, user, can_edit, login === user_login);
988
+
989
+ current_members.appendChild(elem);
990
+ }
991
+ }
992
+
993
+ document.getElementById('members-add').addEventListener('submit', function (e) {
994
+ e.preventDefault();
995
+
996
+ const login = document.getElementById('member-add-name').value.toLowerCase();
997
+ if (!login) {
998
+ return;
999
+ }
1000
+ const privileges = document.getElementById('member-add-privileges').value;
1001
+
1002
+ if (login in members) {
1003
+ showAlert(gettext("Already a member!"));
1004
+ document.getElementById('members-add').reset();
1005
+ return;
1006
+ }
1007
+ postJSON('/api/check_user', {login: login})
1008
+ .then(function (result) {
1009
+ if (!result.exists) {
1010
+ showAlert(gettext("This user doesn't exist!"));
1011
+ return;
1012
+ }
1013
+ return patchMember(login, {privileges: privileges})
1014
+ .then(function () {
1015
+ members[login] = {privileges: privileges};
1016
+ const can_edit = members[user_login].privileges === 'ADMIN';
1017
+ const elem = _memberRow(login, {privileges: privileges}, can_edit, login === user_login);
1018
+ const current_members = document.getElementById('members-current');
1019
+ current_members.insertBefore(elem, current_members.firstChild);
1020
+ document.getElementById('members-add').reset();
1021
+ });
1022
+ })
1023
+ .catch(function (error) {
1024
+ log("Failed to add collaborator", 'error', error);
1025
+ showAlert(gettext("Couldn't add collaborator!"), 'error');
1026
+ });
1027
+ });
1028
+
1029
+ document.getElementById('members-current').addEventListener('submit', function (e) {
1030
+ e.preventDefault();
1031
+ });
1032
+
1033
+
1034
+ /*
1035
+ * Load contents
1036
+ */
1037
+
1038
+ const document_contents = document.getElementById('document-contents');
1039
+
1040
+ function loadDocument(document_id, highlight_id = null, splitParagraphs = true, search_text = null) {
1041
+ loadDocumentHighlights(document_id, highlight_id, splitParagraphs, search_text);
1042
+ }
1043
+
1044
+ function disableEditing(element) {
1045
+ element.addEventListener('keydown', function (event) {
1046
+ event.preventDefault();
1047
+ });
1048
+ element.addEventListener('paste', function (event) {
1049
+ event.preventDefault();
1050
+ });
1051
+ element.addEventListener('input', function (event) {
1052
+ event.preventDefault();
1053
+ });
1054
+ }
1055
+
1056
+ function addDragFunctionality(element) {
1057
+ element.addEventListener('dragstart', function (event) {
1058
+ event.dataTransfer.effectAllowed = 'move';
1059
+ });
1060
+ }
1061
+
1062
+ function scrollToHighlight(highlight_id, search_text=null) {
1063
+ window.setTimeout(function () {
1064
+ let scroll_position = 0,
1065
+ toolbar_height = $("#document-view").prev(".toolbar-container").outerHeight();
1066
+ // Find highlight position and go to it
1067
+ if (highlight_id !== null && !isNaN(highlight_id)) {
1068
+ const hl = highlights['' + highlight_id];
1069
+ const container = document.getElementById('highlight-content');
1070
+ if (hl && container) {
1071
+ const textNodes = collectTextNodes(container);
1072
+ const { fullText, charMap } = buildCharMap(textNodes);
1073
+ const startChar = getCharIndexFromUTF8Index(fullText, hl.start_offset);
1074
+ const endChar = getCharIndexFromUTF8Index(fullText, hl.end_offset);
1075
+ const startEntry = charMap[Math.max(0, startChar)];
1076
+ const endEntry = charMap[Math.min(charMap.length - 1, endChar - 1)];
1077
+
1078
+ if (startEntry && endEntry) {
1079
+ const range = document.createRange();
1080
+ range.setStart(startEntry.node, startEntry.offset);
1081
+ range.setEnd(endEntry.node, endEntry.offset + 1);
1082
+
1083
+ const rects = range.getClientRects();
1084
+ if (rects.length > 0) {
1085
+ scroll_position = rects[0].top + window.scrollY - 25;
1086
+ }
1087
+
1088
+ // Flash only the block elements that contain the highlighted text
1089
+ const startBlock = startEntry.node.parentElement.closest('p, .document-chunk');
1090
+ const endBlock = endEntry.node.parentElement.closest('p, .document-chunk');
1091
+ if (startBlock) startBlock.classList.add('flash-once');
1092
+ if (endBlock && endBlock !== startBlock) endBlock.classList.add('flash-once');
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ if(search_text !== null) {
1098
+ search_text = decodeURI(search_text.toLowerCase());
1099
+ let lastIndex = search_text.lastIndexOf('/'),
1100
+ position = parseInt(search_text.substring(lastIndex+1));
1101
+ search_text = search_text.substring(0, lastIndex);
1102
+ log(`Go to ${position+1} occurence of ${search_text}`);
1103
+
1104
+ let elements = $(`*:contains_insensitive("${search_text}")`),
1105
+ count= 0,
1106
+ nb_occurences_in_container = 0,
1107
+ goto = null;
1108
+ elements.each(function() {
1109
+ if($(this)[0].children.length === 0){
1110
+ if($(this).parents('#document-contents').length) {
1111
+ nb_occurences_in_container = $(this).text().toLowerCase().split(search_text).length - 1;
1112
+ if((position >= count) && (position <= (count + nb_occurences_in_container))) {
1113
+ goto = $(this);
1114
+ }
1115
+ count += nb_occurences_in_container;
1116
+ }
1117
+ }
1118
+ });
1119
+ if(goto) {
1120
+ scroll_position = goto.offset().top;
1121
+ goto.addClass('flash-once');
1122
+ } else {
1123
+ log('Text container not found..')
1124
+ }
1125
+ }
1126
+ window.scrollTo({
1127
+ top: scroll_position - toolbar_height*3,
1128
+ behavior: 'instant'
1129
+ });
1130
+ }, 0);
1131
+ }
1132
+
1133
+ function updateLinksTarget(document_contents) {
1134
+ $("A", document_contents).each(function(){
1135
+ $(this).attr('target', '_blank');
1136
+ });
1137
+ }
1138
+
1139
+ function updateDocumentExportButton(project_id, document_id) {
1140
+ const items = document.querySelectorAll('#document-export-dropdown-menu a');
1141
+ for (let i = 0; i < items.length; ++i) {
1142
+ const ext = items[i].getAttribute('data-extension');
1143
+ if (items[i].getAttribute('data-document') !== 'false') {
1144
+ items[i].setAttribute('href', `${base_path}/project/${project_id}/export/document/${document_id}.${ext}`);
1145
+ items[i].style.display = '';
1146
+ } else {
1147
+ items[i].style.display = 'none';
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ function updateDocumentLinkHighlight(current_document) {
1153
+ let document_links = document.getElementsByClassName('document-link-current');
1154
+ for (let i = document_links.length - 1; i >= 0; --i) {
1155
+ document_links[i].classList.remove('document-link-current');
1156
+ }
1157
+ let document_link = document.getElementById('document-link-' + current_document);
1158
+ if (document_link) {
1159
+ document_link.classList.add('document-link-current');
1160
+ }
1161
+
1162
+ // Also update treeview highlighting if the function exists
1163
+ if (typeof updateDocumentTreeviewHighlight === 'function') {
1164
+ updateDocumentTreeviewHighlight(current_document);
1165
+ }
1166
+ }
1167
+
1168
+ /**
1169
+ * Collect all text nodes under a DOM element in document order.
1170
+ */
1171
+ function collectTextNodes(node) {
1172
+ const textNodes = [];
1173
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
1174
+ let n;
1175
+ while ((n = walker.nextNode())) {
1176
+ textNodes.push(n);
1177
+ }
1178
+ return textNodes;
1179
+ }
1180
+
1181
+ // showTagUnderlineTooltip, hideTagUnderlineTooltip, applyTagUnderlines, loadTag
1182
+ // moved to highlight/extracts.js
1183
+
1184
+ // Load the document if the URL includes one
1185
+ setTimeout(function () {
1186
+ const _document_url_highlight = new RegExp(`^${escapeRegExp(base_path)}/project/([0-9]+)/document/([0-9]+)(/highlight/([0-9]+))?`);
1187
+ // const _document_url_highlight = new RegExp(`^${escapeRegExp(base_path)}/project/([0-9]+)/document/([0-9]+)(/highlight/([0-9]+))+`),
1188
+ const _document_url_search_text = new RegExp(`^${escapeRegExp(base_path)}/project/([0-9]+)/document/([0-9]+)(/search/([^\/]+/[0-9]+))+`);
1189
+ // Don't use RegExp literals https://github.com/python-babel/babel/issues/640
1190
+ let m = window.location.pathname.match(_document_url_highlight),
1191
+ m2 = window.location.pathname.match(_document_url_search_text);
1192
+ if (m) {
1193
+ let h_id = null;
1194
+ if(typeof m[4] !== 'undefined') {
1195
+ h_id = parseInt(m[4]);
1196
+ }
1197
+ loadDocument(parseInt(m[2]), h_id);
1198
+ // Or a search in document text
1199
+ } else if (m2) {
1200
+ let search_text = null;
1201
+ if(typeof m2[4] !== 'undefined') {
1202
+ search_text = m2[4];
1203
+ }
1204
+ loadDocument(parseInt(m2[2]), null, true, search_text);
1205
+ }
1206
+ // Or a tag
1207
+ const _tag_url = new RegExp(`^${escapeRegExp(base_path)}/project/([0-9]+)/highlights/([^/]*)`);
1208
+ m = window.location.pathname.match(_tag_url);
1209
+ if (m) {
1210
+ loadTag(decodeURIComponent(m[2]));
1211
+ }
1212
+ }, 0,);
1213
+
1214
+
1215
+ // Load documents as we go through browser history
1216
+ window.onpopstate = function (e) {
1217
+ if (e.state) {
1218
+ if (e.state.document_id !== undefined) {
1219
+ let highlight_id = (e.state.highlight_id !== undefined ) ? e.state.highlight_id : null,
1220
+ search_text = (e.state.search_text !== undefined ) ? e.state.search_text : null;
1221
+ loadDocument(e.state.document_id, highlight_id, true, search_text);
1222
+ } else if (e.state.tag_path !== undefined) {
1223
+ loadTag(e.state.tag_path);
1224
+ } else {
1225
+ loadDocument(null);
1226
+ }
1227
+ } else {
1228
+ loadDocument(null);
1229
+ }
1230
+ };
1231
+
1232
+ // documents tab filter
1233
+ const DOCUMENT_NOT_FOUND_MESSAGE = gettext("No document or collection found");
1234
+ const alert_document_treeview = $('#alert-title-search');
1235
+
1236
+ function filterTitles() {
1237
+ const filterText = document.getElementById("input-search-titles").value.toLowerCase();
1238
+ alert_document_treeview.addClass('d-none');
1239
+
1240
+ if(filterText === '') {
1241
+ initDocumentsTreeview();
1242
+ return;
1243
+ }
1244
+
1245
+ const filteredCollections = handleDocumentSearch(filterText);
1246
+
1247
+ // Check if there are uncategorized documents that match the search
1248
+ let hasUncategorizedMatches = false;
1249
+ for (let [docId, document] of Object.entries(documents)) {
1250
+ const docCollections = document.collections || [];
1251
+ if (docCollections.length === 0 && document.name.toLowerCase().includes(filterText)) {
1252
+ hasUncategorizedMatches = true;
1253
+ break;
1254
+ }
1255
+ }
1256
+
1257
+ initDocumentsTreeview(filteredCollections);
1258
+
1259
+ // Only show "no results" message if there are no filtered collections AND no uncategorized matches
1260
+ if (filteredCollections.length === 0 && !hasUncategorizedMatches) {
1261
+ alert_document_treeview.removeClass('d-none');
1262
+ alert_document_treeview.html(`<strong>${DOCUMENT_NOT_FOUND_MESSAGE}</strong>`);
1263
+ }
1264
+ }
1265
+
1266
+ document.getElementById("input-search-titles").addEventListener('input', filterTitles);
1267
+
1268
+
1269
+ /*
1270
+ * Attach input change for tag search event
1271
+ */
1272
+ const TAG_NOT_FOUND_MESSAGE = gettext("No tag found");
1273
+ const alert_tag_treeview = $('#alert-tag-treeview');
1274
+ const alert_tag_highlight_treeview = $('#alert-tag-highlight-treeview');
1275
+
1276
+ document.getElementById('input-search-tags').addEventListener('input', function () {
1277
+ const filterText = $(this).val().toLowerCase();
1278
+ alert_tag_treeview.addClass('d-none')
1279
+ if(filterText === '') {
1280
+ initTagsTreeview();
1281
+ collapseAllNodes(treeview_tags);
1282
+ return;
1283
+ }
1284
+ const filteredTags = handleSearch(filterText);
1285
+ initTagsTreeview(filteredTags);
1286
+ expandAllNodes(treeview_tags);
1287
+ if (filteredTags.length === 0) {
1288
+ alert_tag_treeview.removeClass('d-none')
1289
+ alert_tag_treeview.html(`<strong>${TAG_NOT_FOUND_MESSAGE}</strong>`)
1290
+ }
1291
+ });
1292
+
1293
+ function saveSelectedNodes(treeview) {
1294
+ const selectedNodes = treeview.treeview('getSelected');
1295
+ return selectedNodes.map(node => node.id);
1296
+ }
1297
+
1298
+ function restoreSelectedNodes(treeview, savedNodeIds) {
1299
+ savedNodeIds.forEach(id => {
1300
+ const nodeId = getNodeIdFromTreeviewByTagId(treeview, id)
1301
+ selectNode(treeview, nodeId);
1302
+ });
1303
+ }
1304
+
1305
+ let savedSelections = [];
1306
+
1307
+ document.getElementById('input-search-tags-highlight').addEventListener('input', function () {
1308
+
1309
+ const filterText = $(this).val().toLowerCase();
1310
+ hideSearchErrorMessage();
1311
+
1312
+ // save selected tags
1313
+ const tempSavedSelections = saveSelectedNodes(treeview_tags_highlight);
1314
+
1315
+ tempSavedSelections.forEach(id => {
1316
+ if (!savedSelections.includes(id)) {
1317
+ savedSelections.push(id);
1318
+ }
1319
+ });
1320
+
1321
+ const filteredTags = handleSearch(filterText);
1322
+ initTagsHighlightTreeview(filteredTags);
1323
+ if (filteredTags.length === 0) {
1324
+ showSearchErrorMessage();
1325
+ alert_tag_highlight_treeview.html(`<strong>${TAG_NOT_FOUND_MESSAGE}</strong>`)
1326
+ }
1327
+
1328
+ // Restore tags selections
1329
+ restoreSelectedNodes(treeview_tags_highlight, savedSelections);
1330
+ });
1331
+
1332
+ enableTypeToFocusSearch('#left-panel', '#input-search-tags');
1333
+ enableTypeToFocusSearch('#highlight-add-modal', '#input-search-tags-highlight');
1334
+
1335
+ function showSearchErrorMessage() {
1336
+ alert_tag_highlight_treeview.removeClass('d-none');
1337
+ }
1338
+
1339
+ function hideSearchErrorMessage() {
1340
+ alert_tag_highlight_treeview.addClass('d-none');
1341
+ }
1342
+
1343
+ function updateToggleButtons() {
1344
+ const isDesktop = window.innerWidth > 768;
1345
+
1346
+ const leftButton = document.getElementById('toggle-left-panel');
1347
+ const rightButton = document.getElementById('toggle-right-panel');
1348
+
1349
+ if (isDesktop) {
1350
+ leftButton.querySelector('i').className = 'fas fa-chevron-left';
1351
+ rightButton.querySelector('i').className = 'fas fa-chevron-right';
1352
+ } else {
1353
+ leftButton.querySelector('i').className = 'fas fa-chevron-down';
1354
+ rightButton.querySelector('i').className = 'fas fa-chevron-down';
1355
+ }
1356
+ }
1357
+
1358
+ document.getElementById('toggle-left-panel').addEventListener('click', function() {
1359
+ const leftPanel = document.getElementById('left-panel');
1360
+ leftPanel.classList.toggle('reduced-col');
1361
+ if (window.innerWidth > 768) {
1362
+ adjustMiddlePanelWidth();
1363
+ }
1364
+ });
1365
+
1366
+ document.getElementById('toggle-right-panel').addEventListener('click', function() {
1367
+ const rightPanel = document.getElementById('right-panel');
1368
+ rightPanel.classList.toggle('reduced-col');
1369
+ if (window.innerWidth > 768) {
1370
+ adjustMiddlePanelWidth();
1371
+ }
1372
+ });
1373
+
1374
+ function adjustMiddlePanelWidth() {
1375
+ const leftPanel = document.getElementById('left-panel');
1376
+ const rightPanel = document.getElementById('right-panel');
1377
+ const middlePanel = document.getElementById('middle-panel');
1378
+
1379
+ // Desktop behavior
1380
+ if (window.innerWidth > 768) {
1381
+ let leftWidth = leftPanel.classList.contains('reduced-col') ? '5%' : '20.8333%';
1382
+ let rightWidth = rightPanel.classList.contains('reduced-col') ? '5%' : '20.8333%';
1383
+ let middleWidth = `calc(100% - ${leftWidth} - ${rightWidth})`;
1384
+ middlePanel.style.setProperty('width', middleWidth, 'important');
1385
+ } else {
1386
+ // Mobile behavior
1387
+ middlePanel.style.setProperty('width', '100%', 'important');
1388
+ }
1389
+
1390
+ loadDocument(current_document);
1391
+ }
1392
+
1393
+ document.addEventListener('DOMContentLoaded', () => {
1394
+
1395
+ updateToggleButtons();
1396
+
1397
+ const scrollBtn = document.getElementById('scroll-to-top-button');
1398
+
1399
+ window.addEventListener('scroll', function() {
1400
+ if ((window.pageYOffset || document.documentElement.scrollTop) > 300) {
1401
+ scrollBtn.classList.remove('d-none');
1402
+ } else {
1403
+ scrollBtn.classList.add('d-none');
1404
+ }
1405
+ });
1406
+
1407
+ scrollBtn.addEventListener('click', function() {
1408
+ window.scrollTo({
1409
+ top: 0,
1410
+ behavior: 'smooth'
1411
+ });
1412
+ });
1413
+ });
1414
+
1415
+ function debounce(func, wait) {
1416
+ let timeout;
1417
+ return function executedFunction(...args) {
1418
+ const later = () => {
1419
+ clearTimeout(timeout);
1420
+ func(...args);
1421
+ };
1422
+ clearTimeout(timeout);
1423
+ timeout = setTimeout(later, wait);
1424
+ };
1425
+ }
1426
+
1427
+ window.addEventListener('resize', debounce(() => {
1428
+ // Issue in test
1429
+ // loadDocument(current_document);
1430
+ }, 250));
1431
+
1432
+ /* Scroll hide/show navbar handled by utilities.js */
1433
+
1434
+
1435
+
1436
+ function editCollection(collectionId) {
1437
+ const collection = findCollection(collectionId);
1438
+ if (!collection) {
1439
+ log('Collection not found:', 'error', {collectionId});
1440
+ return;
1441
+ }
1442
+
1443
+ document.getElementById('collection-add-form').reset();
1444
+ document.getElementById('collection-add-id').value = '' + collectionId;
1445
+ document.getElementById('collection-add-name').value = collection.name;
1446
+ document.getElementById('collection-add-description').value = collection.description || '';
1447
+
1448
+ initCollectionHierarchySelect();
1449
+
1450
+ if (collection.parent) {
1451
+ document.getElementById('collection-add-parent-id').value = collection.parent;
1452
+ const parentCollection = findCollection(collection.parent);
1453
+ if (parentCollection) {
1454
+ document.getElementById('dropdownMenuButtonCollection').innerText = parentCollection.name;
1455
+ }
1456
+ } else {
1457
+ document.getElementById('collection-add-parent-id').value = '';
1458
+ document.getElementById('dropdownMenuButtonCollection').innerText = gettext('None');
1459
+ }
1460
+
1461
+ populateDocumentsList(collectionId);
1462
+
1463
+ document.getElementById('collection-add-label-new').style.display = 'none';
1464
+ document.getElementById('collection-add-label-change').style.display = '';
1465
+ document.getElementById('collection-add-cancel').style.display = 'none';
1466
+ document.getElementById('collection-add-delete').style.display = '';
1467
+
1468
+ $('#collection-add-modal').modal('show').drags({handle: '.modal-header'});
1469
+ }
1470
+
1471
+ function deleteCollection(collectionId) {
1472
+ const collection = findCollection(collectionId);
1473
+ if (!collection) {
1474
+ log('Collection not found:', 'error', {collectionId});
1475
+ return;
1476
+ }
1477
+
1478
+ if (collection.children && collection.children.length > 0) {
1479
+ showCollectionDeleteOptions(collection);
1480
+ } else {
1481
+ showConfirm(gettext("Are you sure you want to delete the collection '%(name)s'? Its documents will be moved.", {name: collection.name}), function () {
1482
+ deleteURL(`/api/project/${project_id}/collection/${collectionId}`)
1483
+ .then(function() {
1484
+ })
1485
+ .catch(function(error) {
1486
+ log('Error deleting collection:', 'error', {error});
1487
+ showAlert(gettext('Error deleting collection: ') + (error.error || error), 'error');
1488
+ });
1489
+ }, true);
1490
+ }
1491
+ }
1492
+
1493
+ function showCollectionDeleteOptions(collection, successCallback = null) {
1494
+ const message = gettext("The collection '%(name)s' has subcollections. How do you want to proceed?", {name: collection.name});
1495
+
1496
+ const deleteAllLabel = gettext('Delete all');
1497
+ const deleteAllDescription = gettext('Deletes this collection and all its subcollections recursively.');
1498
+ const deleteOnlyLabel = gettext('Delete only');
1499
+ const deleteOnlyDescription = gettext('Deletes only this collection. Subcollections will become root collections (parent removed).');
1500
+ const cancelLabel = gettext('Cancel');
1501
+
1502
+ const legend = `
1503
+ <div class="alert alert-info mt-3 mb-0">
1504
+ <strong>${deleteAllLabel}:</strong> ${deleteAllDescription}<br>
1505
+ <strong>${deleteOnlyLabel}:</strong> ${deleteOnlyDescription}
1506
+ </div>
1507
+ `;
1508
+
1509
+ $('#confirmMessage').html(message + legend);
1510
+ $('#confirmModalLabel').text(gettext('Delete collection'));
1511
+
1512
+ const originalFooter = $('#confirmModal .modal-footer').html();
1513
+
1514
+ const customFooter = `
1515
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${cancelLabel}</button>
1516
+ <button type="button" class="btn btn-danger" id="deleteAllButton">${deleteAllLabel}</button>
1517
+ <button type="button" class="btn btn-warning" id="deleteOnlyButton">${deleteOnlyLabel}</button>
1518
+ `;
1519
+
1520
+ $('#confirmModal .modal-footer').html(customFooter);
1521
+ $('#confirmModal').modal('show');
1522
+
1523
+ $('#deleteAllButton, #deleteOnlyButton').off('click');
1524
+
1525
+ $('#deleteAllButton').on('click', function() {
1526
+ $('#confirmModal').modal('hide');
1527
+ deleteURL(`/api/project/${project_id}/collection/${collection.id}`, {delete_mode: 'cascade'})
1528
+ .then(function() {
1529
+ log('Collection and children deleted successfully');
1530
+ if (successCallback) {
1531
+ successCallback();
1532
+ }
1533
+ })
1534
+ .catch(function(error) {
1535
+ log('Error deleting collection:', 'error', {error});
1536
+ showAlert(gettext('Error deleting collection: ') + (error.error || error), 'error');
1537
+ });
1538
+ $('#confirmModal .modal-footer').html(originalFooter);
1539
+ });
1540
+
1541
+ $('#deleteOnlyButton').on('click', function() {
1542
+ $('#confirmModal').modal('hide');
1543
+ deleteURL(`/api/project/${project_id}/collection/${collection.id}`, {delete_mode: 'orphan'})
1544
+ .then(function() {
1545
+ log('Collection deleted, children orphaned successfully');
1546
+ if (successCallback) {
1547
+ successCallback();
1548
+ }
1549
+ })
1550
+ .catch(function(error) {
1551
+ log('Error deleting collection:', 'error', {error});
1552
+ showAlert(gettext('Error deleting collection: ') + (error.error || error), 'error');
1553
+ });
1554
+ $('#confirmModal .modal-footer').html(originalFooter);
1555
+ });
1556
+
1557
+ $('#confirmModal').on('hidden.bs.modal', function() {
1558
+ $('#confirmModal .modal-footer').html(originalFooter);
1559
+ $('#confirmModal').off('hidden.bs.modal');
1560
+ });
1561
+ }
1562
+
1563
+ function removeCollectionParent(collectionId) {
1564
+ const collection = findCollection(collectionId);
1565
+ if (!collection) {
1566
+ log('Collection not found:', 'error', {collectionId});
1567
+ return;
1568
+ }
1569
+
1570
+ if (!collection.parent) {
1571
+ log('Collection has no parent to remove:', 'warn', {collectionId});
1572
+ return;
1573
+ }
1574
+
1575
+ const parentCollection = findCollection(collection.parent);
1576
+ const parentName = parentCollection ? parentCollection.name : '';
1577
+
1578
+ showConfirm(gettext("Are you sure you want to remove collection '%(name)s' from parent '%(parent)s'?", {name: collection.name, parent: parentName}), function () {
1579
+ const data = {
1580
+ name: collection.name,
1581
+ description: collection.description || '',
1582
+ document_ids: collection.document_ids || [],
1583
+ parent_id: null
1584
+ };
1585
+
1586
+ const url = `/api/project/${project_id}/collection/${collectionId}`;
1587
+
1588
+ postJSON(url, data)
1589
+ .then(function(response) {
1590
+ log('Collection parent removed successfully');
1591
+ })
1592
+ .catch(function(error) {
1593
+ log('Error removing collection parent:', 'error', {error});
1594
+ showAlert(gettext('Error removing collection parent: ') + (error.error || error), 'error');
1595
+ });
1596
+ }, true);
1597
+ }
1598
+
1599
+ function createChildCollection(parentId) {
1600
+ const parentCollection = findCollection(parentId);
1601
+ if (!parentCollection) {
1602
+ log('Parent collection not found:', 'error', {parentId});
1603
+ return;
1604
+ }
1605
+
1606
+ initCollectionHierarchySelect();
1607
+ resetCollectionUI();
1608
+
1609
+ document.getElementById('collection-add-form').reset();
1610
+ document.getElementById('collection-add-id').value = '';
1611
+
1612
+ document.getElementById('collection-add-parent-id').value = parentId;
1613
+ document.getElementById('dropdownMenuButtonCollection').innerText = parentCollection.name;
1614
+
1615
+ populateDocumentsList();
1616
+
1617
+ document.getElementById('collection-add-label-new').style.display = '';
1618
+ document.getElementById('collection-add-label-change').style.display = 'none';
1619
+ document.getElementById('collection-add-cancel').style.display = '';
1620
+ document.getElementById('collection-add-delete').style.display = 'none';
1621
+
1622
+ $('#collection-add-modal').modal('show').drags({handle: '.modal-header'});
1623
+ }
1624
+
1625
+ const collection_add_modal = document.getElementById('collection-add-modal');
1626
+
1627
+ function createCollection() {
1628
+ document.getElementById('collection-add-form').reset();
1629
+ document.getElementById('collection-add-id').value = '';
1630
+
1631
+ initCollectionHierarchySelect();
1632
+
1633
+ populateDocumentsList();
1634
+
1635
+ document.getElementById('collection-add-label-new').style.display = '';
1636
+ document.getElementById('collection-add-label-change').style.display = 'none';
1637
+ document.getElementById('collection-add-cancel').style.display = '';
1638
+ document.getElementById('collection-add-delete').style.display = 'none';
1639
+
1640
+ resetCollectionUI();
1641
+
1642
+ $('#collection-add-modal').modal('show').drags({handle: '.modal-header'});
1643
+ }
1644
+
1645
+ $('#collection-add-form').on('submit', function(e) {
1646
+ e.preventDefault();
1647
+ const collectionId = $('#collection-add-id').val();
1648
+ const name = $('#collection-add-name').val().trim();
1649
+ const description = $('#collection-add-description').val().trim();
1650
+ const parentId = $('#collection-add-parent-id').val();
1651
+
1652
+ if (!name) {
1653
+ showAlert(gettext('Collection name is required'), 'error');
1654
+ return;
1655
+ }
1656
+
1657
+ if (collectionId && parentId && parseInt(collectionId, 10) === parseInt(parentId, 10)) {
1658
+ showAlert(gettext('A collection cannot be its own parent.'), 'error');
1659
+ return;
1660
+ }
1661
+
1662
+ const selectedDocs = Array.from(selectedDocuments);
1663
+
1664
+ const data = {
1665
+ name: name,
1666
+ description: description,
1667
+ document_ids: selectedDocs,
1668
+ parent_id: parentId || null
1669
+ };
1670
+
1671
+ let url;
1672
+ if (collectionId) {
1673
+ url = `/api/project/${project_id}/collection/${collectionId}`;
1674
+ method = 'POST';
1675
+ } else {
1676
+ url = `/api/project/${project_id}/collection/new`;
1677
+ method = 'POST';
1678
+ }
1679
+
1680
+ postJSON(url, data)
1681
+ .then(function(response) {
1682
+ $('#collection-add-modal').modal('hide');
1683
+ $('#collection-add-form')[0].reset();
1684
+ })
1685
+ .catch(function(error) {
1686
+ const errorMsg = collectionId ?
1687
+ gettext('Error updating collection: ') :
1688
+ gettext('Error creating collection: ');
1689
+ showAlert(errorMsg + (error.error || error), 'error');
1690
+ });
1691
+ });
1692
+
1693
+
1694
+ $('#collection-add-delete').on('click', function(e) {
1695
+ e.preventDefault();
1696
+ const collectionId = $('#collection-add-id').val();
1697
+ if (!collectionId) return;
1698
+
1699
+ const collection = findCollection(parseInt(collectionId));
1700
+ if (!collection) return;
1701
+
1702
+ if (collection.children && collection.children.length > 0) {
1703
+ showCollectionDeleteOptions(collection, function() {
1704
+ $('#collection-add-modal').modal('hide');
1705
+ $('#collection-add-form')[0].reset();
1706
+ showAlert(gettext('Collection deleted!'), 'success');
1707
+ });
1708
+ } else {
1709
+ showConfirm(gettext("Are you sure you want to delete the collection '%(name)s'? Its documents will be moved.", {name: collection.name}), function() {
1710
+ deleteURL(`/api/project/${project_id}/collection/${collectionId}`)
1711
+ .then(function() {
1712
+ $('#collection-add-modal').modal('hide');
1713
+ $('#collection-add-form')[0].reset();
1714
+ showAlert(gettext('Collection deleted!'), 'success');
1715
+ })
1716
+ .catch(function(error) {
1717
+ showAlert(gettext('Error deleting collection: ') + (error.error || error), 'error');
1718
+ });
1719
+ }, true);
1720
+ }
1721
+ });
1722
+
1723
+ $('#collection-reset-selection').on('click', function(e) {
1724
+ e.preventDefault();
1725
+ if (typeof resetDocumentSelection === 'function') {
1726
+ resetDocumentSelection();
1727
+ }
1728
+ });
1729
+
1730
+ function handleDocumentSearch(filterText) {
1731
+ function searchInChildren(item) {
1732
+ const filteredChildren = [];
1733
+
1734
+ item.children.forEach(child => {
1735
+ const childMatches = child.name.toLowerCase().includes(filterText);
1736
+ const childDocumentMatches = searchInDocuments(child);
1737
+ const matchingChildren = searchInChildren(child);
1738
+
1739
+ if (childMatches || childDocumentMatches.length > 0 || matchingChildren.length > 0) {
1740
+ filteredChildren.push({
1741
+ ...child,
1742
+ document_ids: childMatches ? child.document_ids : childDocumentMatches.map(doc => doc.document_id),
1743
+ children: matchingChildren
1744
+ });
1745
+ }
1746
+ });
1747
+
1748
+ return filteredChildren;
1749
+ }
1750
+
1751
+ function searchInDocuments(item) {
1752
+ const matchingDocuments = [];
1753
+
1754
+ if (item.document_ids && item.document_ids.length > 0) {
1755
+ for (let docId of item.document_ids) {
1756
+ const document = documents[docId.toString()];
1757
+ if (document && document.name.toLowerCase().includes(filterText)) {
1758
+ matchingDocuments.push({
1759
+ id: 'document_' + docId,
1760
+ text: document.name,
1761
+ icon: 'fas fa-file-alt',
1762
+ selectable: false,
1763
+ document_id: parseInt(docId)
1764
+ });
1765
+ }
1766
+ }
1767
+ }
1768
+
1769
+ return matchingDocuments;
1770
+ }
1771
+
1772
+ return collections
1773
+ .map(item => {
1774
+ const collectionMatches = item.name.toLowerCase().includes(filterText);
1775
+ const documentMatches = searchInDocuments(item);
1776
+ const filteredChildren = searchInChildren(item);
1777
+
1778
+ // Keep the collection if it matches OR if it has matching children OR if it has matching documents
1779
+ if (collectionMatches || documentMatches.length > 0 || filteredChildren.length > 0) {
1780
+ return {
1781
+ ...item,
1782
+ document_ids: collectionMatches ? item.document_ids : documentMatches.map(doc => doc.document_id),
1783
+ children: filteredChildren
1784
+ };
1785
+ }
1786
+
1787
+ return null;
1788
+ })
1789
+ .filter(item => item !== null);
1790
+ }