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
taguette/convert.py ADDED
@@ -0,0 +1,623 @@
1
+ import asyncio
2
+ import bleach
3
+ import bs4
4
+ import chardet
5
+ import codecs
6
+ import io
7
+ import jinja2
8
+ import logging
9
+ import opentelemetry.trace
10
+ import os
11
+ import pkg_resources
12
+ import prometheus_client
13
+ from prometheus_async.aio import time as prom_async_time
14
+ import shutil
15
+ import subprocess
16
+ from subprocess import CalledProcessError
17
+ import subtitle_parser
18
+ import sys
19
+ import tempfile
20
+ from xml.etree import ElementTree
21
+
22
+ from .utils import log_and_wait_proc, sanitize_filename
23
+
24
+
25
+ logger = logging.getLogger(__name__)
26
+ tracer = opentelemetry.trace.get_tracer(__name__)
27
+
28
+
29
+ BUCKETS = [1.0, 2.0, 3.0, 4.0, 5.0,
30
+ 6.0, 8.0, 10.0, 12.0, 15.0, 18.0,
31
+ 22.0, 26.0, 30.0, 36.0, 42.0, 48.0, 60.0, 90.0]
32
+ PROM_CALIBRE_TOHTML = prometheus_client.Counter(
33
+ 'convert_calibre_tohtml_total',
34
+ "Conversions to HTML using Calibre (calibre_to_html())",
35
+ )
36
+ PROM_CALIBRE_TOHTML_TIME = prometheus_client.Histogram(
37
+ 'convert_calibre_tohtml_seconds',
38
+ "Time to convert to HTML using Calibre (calibre_to_html())",
39
+ buckets=BUCKETS,
40
+ )
41
+ PROM_WVWARE_TOHTML = prometheus_client.Counter(
42
+ 'convert_wvware_tohtml_total',
43
+ "Conversions to HTML using wvHtml (wvware_to_html())",
44
+ )
45
+ PROM_WVWARE_TOHTML_TIME = prometheus_client.Histogram(
46
+ 'convert_wvware_tohtml_seconds',
47
+ "Time to convert to HTML using wvHtml (wvware_to_html())",
48
+ )
49
+ PROM_CALIBRE_FROMHTML = prometheus_client.Counter(
50
+ 'convert_calibre_fromhtml_total',
51
+ "Conversions from HTML using Calibre (calibre_from_html())",
52
+ ['extension'],
53
+ )
54
+ PROM_CALIBRE_FROMHTML_TIME = prometheus_client.Histogram(
55
+ 'convert_calibre_fromhtml_seconds',
56
+ "Time to convert from HTML using Calibre (calibre_from_html())",
57
+ buckets=BUCKETS,
58
+ )
59
+ PROM_CONVERT_PROCESSES = prometheus_client.Gauge(
60
+ 'convert_processes',
61
+ "Number of conversion processes currently running",
62
+ )
63
+ PROM_CONVERT_QUEUE = prometheus_client.Gauge(
64
+ 'convert_queue',
65
+ "Number of conversions waiting to run",
66
+ )
67
+
68
+
69
+ HTML_EXTENSIONS = ('.htm', '.html', '.xhtml')
70
+
71
+
72
+ template_env = jinja2.Environment(
73
+ loader=jinja2.FileSystemLoader(
74
+ [pkg_resources.resource_filename('taguette', 'templates')],
75
+ ),
76
+ autoescape=jinja2.select_autoescape(['html']),
77
+ )
78
+
79
+
80
+ class ConversionError(ValueError):
81
+ """Error converting document.
82
+ """
83
+
84
+
85
+ class UnsupportedFormat(ConversionError):
86
+ """This format is not supported.
87
+ """
88
+
89
+ def __init__(self, msg="Unsupported file format",):
90
+ super(UnsupportedFormat, self).__init__(msg)
91
+
92
+
93
+ PROC_TERM_GRACE = 5 # Wait 5s after SIGTERM before sending SIGKILL
94
+
95
+ PROC_MAX_CONCURRENT = 4 # Maximum concurrent conversion processes
96
+
97
+
98
+ class MeasuredSemaphore(asyncio.Semaphore):
99
+ def __init__(self, value, metric_acquired, metric_waiting):
100
+ super(MeasuredSemaphore, self).__init__(value)
101
+ self._metric_acquired = metric_acquired
102
+ self._metric_waiting = metric_waiting
103
+
104
+ async def acquire(self):
105
+ self._metric_waiting.inc()
106
+ ret = await super(MeasuredSemaphore, self).acquire()
107
+ self._metric_waiting.dec()
108
+ self._metric_acquired.inc()
109
+ return ret
110
+
111
+ def release(self):
112
+ super(MeasuredSemaphore, self).release()
113
+ self._metric_acquired.dec()
114
+
115
+
116
+ subprocess_sem = MeasuredSemaphore(
117
+ PROC_MAX_CONCURRENT,
118
+ PROM_CONVERT_PROCESSES,
119
+ PROM_CONVERT_QUEUE,
120
+ )
121
+
122
+
123
+ # Windows only supports subprocesses with the asyncio ProactorEventLoop
124
+ # However tornado only supports the SelectorEventLoop
125
+ # https://github.com/tornadoweb/tornado/issues/2608
126
+ # For now we can't use asyncio subprocesses on Windows
127
+ async def _check_call_threadpool(cmd, timeout, env=None):
128
+ async with subprocess_sem:
129
+ with tracer.start_as_current_span(
130
+ 'taguette/subprocess',
131
+ attributes={'command': ' '.join(cmd)},
132
+ ):
133
+ return await asyncio.get_event_loop().run_in_executor(
134
+ None,
135
+ lambda: subprocess.check_call(cmd, timeout=timeout, env=env),
136
+ )
137
+
138
+
139
+ async def _check_call_asyncio(cmd, timeout, env=None):
140
+ async with subprocess_sem:
141
+ with tracer.start_as_current_span(
142
+ 'taguette/subprocess',
143
+ attributes={'command': ' '.join(cmd)},
144
+ ):
145
+ proc = await asyncio.create_subprocess_exec(
146
+ *cmd,
147
+ stdout=subprocess.PIPE,
148
+ stderr=subprocess.STDOUT,
149
+ env=env,
150
+ )
151
+ try:
152
+ retcode = await asyncio.wait_for(
153
+ log_and_wait_proc(logger, proc),
154
+ timeout=timeout,
155
+ )
156
+ except asyncio.TimeoutError:
157
+ logger.warning(
158
+ "Process didn't finish before %ds timeout: %r",
159
+ timeout, cmd,
160
+ )
161
+ try:
162
+ proc.terminate()
163
+ try:
164
+ await asyncio.wait_for(proc.wait(), PROC_TERM_GRACE)
165
+ except asyncio.TimeoutError:
166
+ proc.kill()
167
+ except ProcessLookupError:
168
+ pass
169
+ raise asyncio.TimeoutError
170
+ else:
171
+ if retcode != 0:
172
+ raise CalledProcessError(retcode, cmd)
173
+
174
+
175
+ if sys.platform == 'win32':
176
+ check_call = _check_call_threadpool
177
+ else:
178
+ check_call = _check_call_asyncio
179
+
180
+
181
+ # Something to HTML
182
+
183
+
184
+ def get_html_body(body):
185
+ # Use beautifulsoup to remove head, script, style elements
186
+ # (bleach can do that, but would keep the text inside them)
187
+ soup = bs4.BeautifulSoup(body, 'html5lib')
188
+ for tag in ['head', 'script', 'style']:
189
+ for e in soup.find_all(tag):
190
+ e.extract()
191
+ # Update 'src' URLs
192
+ for e in soup.find_all('img'):
193
+ e.attrs['src'] = '/static/missing.png'
194
+ # Update 'href' URLs
195
+ for e in soup.find_all('a'):
196
+ if 'href' not in e.attrs:
197
+ e.replace_with(e.text)
198
+ continue
199
+ href = e.attrs['href'].lower()
200
+ if not (href.startswith('http://') or
201
+ href.startswith('https://') or
202
+ href.startswith('mailto:')):
203
+ e.attrs['title'] = e.attrs['href']
204
+ del e.attrs['href']
205
+ else:
206
+ if 'title' in e.attrs:
207
+ del e.attrs['title']
208
+
209
+ # Cleanup empty tags
210
+ for x in soup.find_all():
211
+ if len(x.get_text(strip=True)) == 0 and x.name not in ['br', 'img']:
212
+ x.extract()
213
+
214
+ # Back to string
215
+ body = str(soup)
216
+ del soup
217
+
218
+ # Use bleach to sanitize the content
219
+ body = bleach.clean(
220
+ body,
221
+ tags=['p', 'br', 'code', 'blockquote', 'pre', # formatting
222
+ 'sub', 'sup', 'caption',
223
+ 'a', 'img', # non-text
224
+ 'h1', 'h2', 'h3', 'h4', 'h5', # headers
225
+ 'strong', 'em', 'b', 'u', 'q', 'del', # emphasis
226
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd', # lists
227
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', # tables
228
+ 'colgroup', 'col', # columns
229
+ ],
230
+ attributes={'a': ['href', 'title'], 'img': ['src']},
231
+ strip=True,
232
+ )
233
+
234
+ body = body.strip()
235
+
236
+ return body
237
+
238
+
239
+ def is_html_safe(text):
240
+ """Check whether the given HTML is safe.
241
+
242
+ For situation where we cannot run `get_html_body()`, this will throw out
243
+ unsafe HTML.
244
+ """
245
+ if isinstance(text, bytes):
246
+ try:
247
+ text = text.decode('utf-8')
248
+ except UnicodeDecodeError as e:
249
+ logging.warning("is_html_safe(): %s", e)
250
+ return False
251
+ elif not isinstance(text, str):
252
+ raise TypeError("is_html_safe() expects str or bytes, not %r" % (
253
+ type(text).__name__,
254
+ ))
255
+
256
+ soup = bs4.BeautifulSoup(text, 'html5lib')
257
+ # Check 'src' URLs
258
+ for e in soup.find_all('img'):
259
+ if e.attrs['src'] != '/static/missing.png':
260
+ return False
261
+
262
+ # Use bleach to sanitize the content
263
+ cleaned = bleach.clean(
264
+ text,
265
+ tags=['p', 'br', 'code', 'blockquote', 'pre', # formatting
266
+ 'sub', 'sup', 'caption',
267
+ 'a', 'img', # non-text
268
+ 'h1', 'h2', 'h3', 'h4', 'h5', # headers
269
+ 'strong', 'em', 'b', 'u', 'q', 'del', # emphasis
270
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd', # lists
271
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', # tables
272
+ 'colgroup', 'col', # columns
273
+ ],
274
+ attributes={'a': ['href', 'title'], 'img': ['src', 'width', 'height']},
275
+ strip=True,
276
+ )
277
+
278
+ # text.strip() == cleaned.strip()
279
+ # This doesn't work because bleach changed from outputting <br/> to <br>
280
+
281
+ return (
282
+ text.strip().replace('/>', '>')
283
+ == cleaned.strip().replace('/>', '>')
284
+ )
285
+
286
+
287
+ @tracer.start_as_current_span('taguette/convert/calibre_to_html')
288
+ @prom_async_time(PROM_CALIBRE_TOHTML_TIME)
289
+ async def calibre_to_html(input_filename, temp_dir, config):
290
+ PROM_CALIBRE_TOHTML.inc()
291
+
292
+ output_dir = os.path.join(temp_dir, 'output')
293
+ output = []
294
+ convert = 'ebook-convert'
295
+ if os.environ.get('CALIBRE'):
296
+ convert = os.path.join(os.environ['CALIBRE'], convert)
297
+ cmd = [convert, input_filename, output_dir]
298
+ if os.path.splitext(input_filename)[1].lower() == '.pdf':
299
+ cmd.append('--no-images')
300
+ cmd_heuristics = (cmd + ['--enable-heuristics']
301
+ + ['--disable-unwrap-lines']
302
+ + ['--input-encoding']
303
+ + ['UTF-8']
304
+ )
305
+ logger.info("Running: %s", ' '.join(cmd_heuristics))
306
+ try:
307
+ try:
308
+ await check_call(cmd_heuristics, config['CONVERT_TO_HTML_TIMEOUT'],
309
+ env=dict(os.environ, TMPDIR=temp_dir))
310
+ except asyncio.TimeoutError:
311
+ logger.warning("Calibre timed out, trying again without "
312
+ "heuristics...")
313
+ try:
314
+ await check_call(cmd, config['CONVERT_TO_HTML_TIMEOUT'],
315
+ env=dict(os.environ, TMPDIR=temp_dir))
316
+ except asyncio.TimeoutError:
317
+ raise ConversionError("Calibre took too long and was stopped")
318
+ except OSError:
319
+ raise ConversionError("Calibre is not available")
320
+ except CalledProcessError:
321
+ raise ConversionError("Calibre couldn't convert that file")
322
+ logger.info("ebook-convert successful")
323
+
324
+ # Locate OEB manifest
325
+ manifests = [e.lower() for e in os.listdir(output_dir)]
326
+ manifests = [e for e in manifests if e.endswith('.opf')]
327
+ if not manifests:
328
+ logger.error("No OPF manifest in Calibre's output")
329
+ raise ConversionError("Invalid output from Calibre")
330
+ elif manifests == ['content.opf']:
331
+ manifest = 'content.opf' # All good
332
+ elif len(manifests) > 1 and 'content.opf' in manifests:
333
+ logger.warning("Calibre's output contains multiple OPF "
334
+ "manifests! Using content.opf")
335
+ manifest = 'content.opf'
336
+ elif len(manifests) == 1 and manifests[0] != 'content.opf':
337
+ manifest, = manifests
338
+ logger.warning("Unusual name for OPF manifest in Calibre's "
339
+ "output: %r", manifest)
340
+ else:
341
+ logger.error("Multiple OPF manifests in Calibre's output: "
342
+ "%r" % manifests)
343
+ raise ConversionError("Invalid output from Calibre")
344
+
345
+ size = os.stat(os.path.join(output_dir, manifest)).st_size
346
+ if size > config['OPF_OUT_SIZE_LIMIT']:
347
+ logger.warning("OPF manifest is %d bytes; aborting", size)
348
+ raise ConversionError("Output manifest is too long")
349
+
350
+ # Open OEB manifest
351
+ logger.info("Parsing OPF manifest %s", manifest)
352
+ tree = ElementTree.parse(os.path.join(output_dir, manifest))
353
+ root = tree.getroot()
354
+ ns = '{http://www.idpf.org/2007/opf}'
355
+ if root.tag not in ('package', ns + 'package'):
356
+ logger.error("Invalid root tag in OPF manifest: %r", root.tag)
357
+ raise ConversionError("Invalid output from Calibre")
358
+ manifests = [tag for tag in root
359
+ if tag.tag in ('manifest', ns + 'manifest')]
360
+ if len(manifests) != 1:
361
+ logger.error("OPF has %d <manifest> nodes", len(manifests))
362
+ raise ConversionError("Invalid output from Calibre")
363
+ manifest, = manifests
364
+ spines = [tag for tag in root
365
+ if tag.tag in ('spine', ns + 'spine')]
366
+ if len(spines) != 1:
367
+ logger.error("OPF has %d <spine> nodes", len(spines))
368
+ raise ConversionError("Invalid output from Calibre")
369
+ spine, = spines
370
+
371
+ # Read <manifest>
372
+ items = {}
373
+ for item in manifest:
374
+ if item.tag not in ('item', ns + 'item'):
375
+ continue
376
+ try:
377
+ name = item.attrib['href']
378
+ mimetype = item.attrib['media-type']
379
+ id_ = item.attrib['id']
380
+ except KeyError:
381
+ logger.error("Missing attributes from <item> in OPF "
382
+ "manifest. Present: %s",
383
+ ', '.join(item.attrib))
384
+ raise ConversionError("Invalid output from Calibre")
385
+ else:
386
+ items[id_] = name, mimetype
387
+ logger.info("Read %d items", len(items))
388
+
389
+ # Read <spine>
390
+ size = 0
391
+ for item in spine:
392
+ if item.tag not in ('itemref', ns + 'itemref'):
393
+ continue
394
+ try:
395
+ idref = item.attrib['idref']
396
+ except KeyError:
397
+ logger.error("Missing attribute 'idref' from <itemref> in "
398
+ "OPF manifest. Present: %s",
399
+ ', '.join(item.attrib))
400
+ raise ConversionError("Invalid output from Calibre")
401
+ try:
402
+ output_name, output_mimetype = items[idref]
403
+ except KeyError:
404
+ logger.error("Spine entry references missing item %r",
405
+ idref)
406
+ raise ConversionError("Invalid output from Calibre")
407
+ if output_mimetype not in HTML_MIMETYPES:
408
+ logger.warning("Ignoring item %r, mimetype=%r",
409
+ idref, output_mimetype)
410
+ continue
411
+ output_filename = os.path.join(output_dir, output_name)
412
+ if not os.path.isfile(output_filename):
413
+ logger.error("Missing file from output dir: %r",
414
+ output_name)
415
+ raise ConversionError("Invalid output from Calibre")
416
+
417
+ # Read output
418
+ logger.info("Reading in %r", output_name)
419
+ size += os.stat(output_filename).st_size
420
+ if size > config['HTML_OUT_SIZE_LIMIT']:
421
+ logger.warning(
422
+ "File is %d bytes for a total of %d bytes; aborting",
423
+ os.stat(output_filename).st_size,
424
+ size,
425
+ )
426
+ raise ConversionError("Output file is too long")
427
+ with open(output_filename, 'rb') as fp:
428
+ output.append(get_html_body(fp.read()))
429
+ # TODO: Store media files
430
+
431
+ # Assemble output
432
+ return '\n'.join(output)
433
+
434
+
435
+ @tracer.start_as_current_span('taguette/convert/wvware_to_html')
436
+ @prom_async_time(PROM_WVWARE_TOHTML_TIME)
437
+ async def wvware_to_html(input_filename, tmp, config):
438
+ PROM_WVWARE_TOHTML.inc()
439
+ output_filename = os.path.join(tmp, 'output.html')
440
+
441
+ # Run WV
442
+ convert = 'wvHtml'
443
+ if os.environ.get('WVHTML'):
444
+ convert = os.environ['WVHTML']
445
+ cmd = [convert, input_filename, output_filename]
446
+ logger.info("Running: %s", ' '.join(cmd))
447
+ try:
448
+ await check_call(cmd, config['CONVERT_TO_HTML_TIMEOUT'])
449
+ except OSError:
450
+ raise ConversionError("Can't call wvHtml to convert Word 97 file")
451
+ except CalledProcessError:
452
+ raise ConversionError("wvHtml couldn't convert that file")
453
+ except asyncio.TimeoutError:
454
+ raise ConversionError("wvHtml took too long and was stopped")
455
+ logger.info("wvHtml successful")
456
+
457
+ # Read output
458
+ with open(output_filename, 'rb') as fp:
459
+ return get_html_body(fp.read())
460
+
461
+
462
+ HTML_MIMETYPES = {'text/html', 'application/xhtml+xml'}
463
+
464
+
465
+ async def to_html(body, content_type, filename, config):
466
+ logger.info("Converting file %r, type %r", filename, content_type)
467
+
468
+ ext = os.path.splitext(filename)[1].lower()
469
+ if ext in HTML_EXTENSIONS:
470
+ return get_html_body(body)
471
+ elif not ext:
472
+ raise ConversionError("This file doesn't have an extension!")
473
+ elif ext == '.doc':
474
+ # Convert file to HTML using WV
475
+ tmp = tempfile.mkdtemp(prefix='taguette_wv_')
476
+ try:
477
+ # Write file to temporary directory
478
+ input_filename = os.path.join(tmp, sanitize_filename(filename))
479
+ with open(input_filename, 'wb') as fp:
480
+ fp.write(body)
481
+
482
+ # Run wvHtml
483
+ return await wvware_to_html(input_filename, tmp, config)
484
+ finally:
485
+ shutil.rmtree(tmp)
486
+ elif ext in ('.srt', '.vtt'):
487
+ # Convert file to HTML using subtitle-parser
488
+
489
+ # Pick the parser class
490
+ if ext == '.vtt':
491
+ parser_cls = subtitle_parser.WebVttParser
492
+ else:
493
+ parser_cls = subtitle_parser.SrtParser
494
+
495
+ # Detect encoding
496
+ charset = chardet.detect(body)['encoding'] or 'utf-8'
497
+ file = io.BytesIO(body)
498
+ file = codecs.getreader(charset)(file)
499
+
500
+ # Parse subtitle file
501
+ try:
502
+ parser = parser_cls(file)
503
+ parser.parse()
504
+ except subtitle_parser.SubtitleError as e:
505
+ raise ConversionError("Invalid subtitle file: %s" % (e,))
506
+
507
+ # Turn the result into HTML
508
+ output = io.StringIO()
509
+ subtitle_parser.render_html(parser.subtitles, output)
510
+ return output.getvalue()
511
+ else:
512
+ # Convert file to HTML using Calibre
513
+ tmp = tempfile.mkdtemp(prefix='taguette_calibre_')
514
+ try:
515
+ # Write file to temporary directory
516
+ input_filename = os.path.join(tmp, filename)
517
+ with open(input_filename, 'wb') as fp:
518
+ fp.write(body)
519
+
520
+ # Run Calibre
521
+ return await calibre_to_html(
522
+ input_filename,
523
+ tmp,
524
+ config,
525
+ )
526
+ finally:
527
+ shutil.rmtree(tmp)
528
+
529
+
530
+ async def to_html_chunks(body, content_type, filename, config):
531
+ html = await to_html(body, content_type, filename, config)
532
+ # TODO: Do chunks
533
+ return html
534
+
535
+
536
+ # HTML to something
537
+
538
+
539
+ @tracer.start_as_current_span('taguette/convert/calibre_from_html')
540
+ @prom_async_time(PROM_CALIBRE_FROMHTML_TIME)
541
+ async def calibre_from_html(html, extension, config):
542
+ PROM_CALIBRE_FROMHTML.labels(extension).inc()
543
+
544
+ # Convert file using Calibre
545
+ tmp = tempfile.mkdtemp(prefix='taguette_calibre_')
546
+ try:
547
+ input_filename = os.path.join(tmp, 'input.html')
548
+ with open(input_filename, 'w', encoding='utf-8') as fp:
549
+ fp.write(html)
550
+ output_filename = os.path.join(tmp, 'output.%s' % extension)
551
+ convert = 'ebook-convert'
552
+ if os.environ.get('CALIBRE'):
553
+ convert = os.path.join(os.environ['CALIBRE'], convert)
554
+ cmd = [convert, input_filename, output_filename,
555
+ '--page-breaks-before=/']
556
+ logger.info("Running: %s", ' '.join(cmd))
557
+ try:
558
+ await check_call(cmd, config['CONVERT_FROM_HTML_TIMEOUT'],
559
+ env=dict(os.environ, TMPDIR=tmp))
560
+ except OSError:
561
+ raise ConversionError("Calibre is not available")
562
+ except CalledProcessError:
563
+ raise ConversionError("Calibre couldn't convert that file")
564
+ except asyncio.TimeoutError:
565
+ raise ConversionError("Calibre took too long and was stopped")
566
+ logger.info("ebook-convert successful")
567
+ if not os.path.isfile(output_filename):
568
+ raise RuntimeError("Output file does not exist")
569
+ except Exception:
570
+ shutil.rmtree(tmp)
571
+ raise
572
+ else:
573
+ def reader():
574
+ try:
575
+ with open(output_filename, 'rb') as fp:
576
+ chunk = fp.read(4096)
577
+ yield chunk
578
+ while len(chunk) == 4096:
579
+ chunk = fp.read(4096)
580
+ if chunk:
581
+ yield chunk
582
+ finally:
583
+ shutil.rmtree(tmp)
584
+
585
+ return reader()
586
+
587
+
588
+ def html_to_html(html, config):
589
+ _ = config
590
+ future = asyncio.get_event_loop().create_future()
591
+ future.set_result([html])
592
+ return future
593
+
594
+
595
+ html_to_extensions = {
596
+ 'html': (html_to_html,
597
+ 'text/html; charset=utf-8'),
598
+ 'doc': (lambda html, config: calibre_from_html(html, 'docx', config),
599
+ 'application/vnd.openxmlformats-officedocument.'
600
+ 'wordprocessingml.document; charset=utf-8'),
601
+ 'docx': (lambda html, config: calibre_from_html(html, 'docx', config),
602
+ 'application/vnd.openxmlformats-officedocument.'
603
+ 'wordprocessingml.document; charset=utf-8'),
604
+ 'pdf': (lambda html, config: calibre_from_html(html, 'pdf', config),
605
+ 'application/pdf'),
606
+ 'rtf': (lambda html, config: calibre_from_html(html, 'rtf', config),
607
+ 'application/rtf'),
608
+ }
609
+ for n in html_to_extensions:
610
+ PROM_CALIBRE_FROMHTML.labels(n).inc(0)
611
+
612
+
613
+ def html_to(html, extension, config):
614
+ try:
615
+ func, mimetype = html_to_extensions[extension.lower()]
616
+ except KeyError:
617
+ raise UnsupportedFormat
618
+ return mimetype, asyncio.ensure_future(func(html, config))
619
+
620
+
621
+ def html_to_plaintext(html):
622
+ soup = bs4.BeautifulSoup(html, 'html5lib')
623
+ return soup.get_text(' ', strip=True)