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.
- CHANGELOG.md +481 -0
- libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
- libreqda-1.2.0.dist-info/METADATA +131 -0
- libreqda-1.2.0.dist-info/RECORD +255 -0
- libreqda-1.2.0.dist-info/WHEEL +4 -0
- libreqda-1.2.0.dist-info/entry_points.txt +4 -0
- scripts/babelrc +8 -0
- scripts/dist/LICENSE.txt +33 -0
- scripts/dist/RELEASE.txt +23 -0
- scripts/dist/macos/libreqda.icns +0 -0
- scripts/dist/macos/macos.spec +43 -0
- scripts/dist/macos/plist.patch +10 -0
- scripts/dist/macos/taguette.icns +0 -0
- scripts/dist/macos/taguette_console_wrapper +4 -0
- scripts/dist/macos.sh +32 -0
- scripts/dist/pyinstaller_entrypoint.py +19 -0
- scripts/dist/windows/windows-installer.iss +51 -0
- scripts/dist/windows/windows.spec +39 -0
- scripts/edugain/worker_update_edugain_idps.py +89 -0
- scripts/edugain/worker_update_edugain_idps.sh +9 -0
- scripts/make_password_reset_link.py +56 -0
- scripts/make_theme.py +99 -0
- scripts/new_db_revision.sh +8 -0
- scripts/register_user.py +76 -0
- scripts/update_pot.sh +33 -0
- scripts/update_translations.sh +17 -0
- scripts/validate.py +140 -0
- scripts/wipe_and_disable_user.py +90 -0
- taguette/__init__.py +13 -0
- taguette/access_control.py +145 -0
- taguette/convert.py +623 -0
- taguette/database/__init__.py +177 -0
- taguette/database/base.py +9 -0
- taguette/database/copy.py +433 -0
- taguette/database/models.py +920 -0
- taguette/demodata.py +688 -0
- taguette/export.py +500 -0
- taguette/extract.py +287 -0
- taguette/import_codebook.py +110 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
- taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
- taguette/loki_handler.py +107 -0
- taguette/main.py +649 -0
- taguette/migrations/env.py +72 -0
- taguette/migrations/script.py.mako +24 -0
- taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
- taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
- taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
- taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
- taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
- taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
- taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
- taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
- taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
- taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
- taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
- taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
- taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
- taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
- taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
- taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
- taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
- taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
- taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
- taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
- taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
- taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
- taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
- taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
- taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
- taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
- taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
- taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
- taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
- taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
- taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
- taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
- taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
- taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
- taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
- taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
- taguette/static/README.md +34 -0
- taguette/static/alt_templates/.gitkeep +0 -0
- taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
- taguette/static/css/custom.css +1696 -0
- taguette/static/css/fa-all.min.css +5 -0
- taguette/static/css/themed.css +3 -0
- taguette/static/css/themed.css.map +25 -0
- taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
- taguette/static/css/vendor/bootstrap.min.css +6 -0
- taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
- taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
- taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
- taguette/static/css/vendor/hierarchy-select.min.css +1 -0
- taguette/static/css/vendor/introjs.min.css +2 -0
- taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
- taguette/static/favicon.ico +0 -0
- taguette/static/js/codebook-viz.js +428 -0
- taguette/static/js/collection/context-menu.js +180 -0
- taguette/static/js/collection/hierarchy-select.js +71 -0
- taguette/static/js/collection/manager.js +217 -0
- taguette/static/js/document/context-menu.js +92 -0
- taguette/static/js/document/custom-select.js +82 -0
- taguette/static/js/document/manager.js +943 -0
- taguette/static/js/document/treeview.js +593 -0
- taguette/static/js/highlight/README.md +18 -0
- taguette/static/js/highlight/crud.js +119 -0
- taguette/static/js/highlight/extracts.js +497 -0
- taguette/static/js/highlight/loader.js +109 -0
- taguette/static/js/highlight/renderer.js +405 -0
- taguette/static/js/login.js +15 -0
- taguette/static/js/permissions.js +153 -0
- taguette/static/js/polling-event.js +145 -0
- taguette/static/js/project/intro.js +524 -0
- taguette/static/js/project/manager.js +0 -0
- taguette/static/js/project/search.js +124 -0
- taguette/static/js/tag/context-menu.js +133 -0
- taguette/static/js/tag/explore-codes.js +400 -0
- taguette/static/js/tag/hierarchy-select.js +69 -0
- taguette/static/js/tag/manager.js +545 -0
- taguette/static/js/tag/treeview.js +613 -0
- taguette/static/js/taguette.js +1790 -0
- taguette/static/js/utilities.js +714 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
- taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
- taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
- taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
- taguette/static/js/vendor/hierarchy-select.min.js +1 -0
- taguette/static/js/vendor/introjs.min.js +17 -0
- taguette/static/js/vendor/jquery-3.7.1.js +2 -0
- taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
- taguette/static/libreqda.ico +0 -0
- taguette/static/logo-h.png +0 -0
- taguette/static/logo.png +0 -0
- taguette/static/logo_blanc.png +0 -0
- taguette/static/missing.png +0 -0
- taguette/static/robots.txt +2 -0
- taguette/static/scss/components/_badge.scss +42 -0
- taguette/static/scss/components/_button.scss +169 -0
- taguette/static/scss/components/_checkbox.scss +40 -0
- taguette/static/scss/components/_context_menu.scss +28 -0
- taguette/static/scss/components/_custom_select.scss +150 -0
- taguette/static/scss/components/_dropdown.scss +43 -0
- taguette/static/scss/components/_form.scss +54 -0
- taguette/static/scss/components/_modal.scss +57 -0
- taguette/static/scss/components/_project_card.scss +163 -0
- taguette/static/scss/components/_tag.scss +308 -0
- taguette/static/scss/components/_treeview.scss +76 -0
- taguette/static/scss/custom.scss +809 -0
- taguette/static/scss/layout/_credits.scss +62 -0
- taguette/static/scss/layout/_footer.scss +6 -0
- taguette/static/scss/layout/_navigation.scss +165 -0
- taguette/static/scss/layout/_sidebar.scss +37 -0
- taguette/static/scss/themes/default/_variables.scss +103 -0
- taguette/static/scss/themes/default/theme.css +3 -0
- taguette/static/scss/themes/default/theme.css.map +26 -0
- taguette/static/scss/themes/default/theme.scss +4 -0
- taguette/static/webfonts/BadScript-OFL.txt +94 -0
- taguette/static/webfonts/BadScript.ttf +0 -0
- taguette/static/webfonts/Montserrat-Light.ttf +0 -0
- taguette/static/webfonts/Montserrat-OFL.txt +93 -0
- taguette/static/webfonts/fa-brands-400.eot +0 -0
- taguette/static/webfonts/fa-brands-400.svg +1184 -0
- taguette/static/webfonts/fa-brands-400.ttf +0 -0
- taguette/static/webfonts/fa-brands-400.woff +0 -0
- taguette/static/webfonts/fa-brands-400.woff2 +0 -0
- taguette/static/webfonts/fa-regular-400.eot +0 -0
- taguette/static/webfonts/fa-regular-400.svg +467 -0
- taguette/static/webfonts/fa-regular-400.ttf +0 -0
- taguette/static/webfonts/fa-regular-400.woff +0 -0
- taguette/static/webfonts/fa-regular-400.woff2 +0 -0
- taguette/static/webfonts/fa-solid-900.eot +0 -0
- taguette/static/webfonts/fa-solid-900.svg +2618 -0
- taguette/static/webfonts/fa-solid-900.ttf +0 -0
- taguette/static/webfonts/fa-solid-900.woff +0 -0
- taguette/static/webfonts/fa-solid-900.woff2 +0 -0
- taguette/templates/alt_templates/.gitkeep +0 -0
- taguette/templates/archived_projects.html +50 -0
- taguette/templates/auth/account.html +74 -0
- taguette/templates/auth/cilogon.login.html +89 -0
- taguette/templates/auth/email_reset_password.html +5 -0
- taguette/templates/auth/external_account.html +60 -0
- taguette/templates/auth/login.html +144 -0
- taguette/templates/auth/reset_password.html +27 -0
- taguette/templates/auth/token_needed.html +7 -0
- taguette/templates/base.html +209 -0
- taguette/templates/bug_report.html +158 -0
- taguette/templates/cookies_prompt.html +15 -0
- taguette/templates/downloads.html +127 -0
- taguette/templates/email_bug_report.txt +9 -0
- taguette/templates/email_reset_password.txt +5 -0
- taguette/templates/error.html +8 -0
- taguette/templates/export_codebook.html +20 -0
- taguette/templates/export_document.html +20 -0
- taguette/templates/export_highlights.html +47 -0
- taguette/templates/index.html +347 -0
- taguette/templates/maintenance.html +8 -0
- taguette/templates/messages.js +74 -0
- taguette/templates/modal/change_document.html +68 -0
- taguette/templates/modal/create_collection.html +100 -0
- taguette/templates/modal/create_document.html +84 -0
- taguette/templates/modal/create_highlight.html +38 -0
- taguette/templates/modal/create_tag.html +73 -0
- taguette/templates/modal/explore_codes.html +58 -0
- taguette/templates/modal/manage_collaborators.html +44 -0
- taguette/templates/modal/merge_tag.html +49 -0
- taguette/templates/new_password.html +32 -0
- taguette/templates/partials/alert.html +16 -0
- taguette/templates/partials/codebook_viz_modal.html +34 -0
- taguette/templates/partials/confirm.html +17 -0
- taguette/templates/partials/document_toolbar.html +37 -0
- taguette/templates/partials/hlinfo.html +13 -0
- taguette/templates/partials/project_card.html +44 -0
- taguette/templates/partials/project_card_archived.html +41 -0
- taguette/templates/partials/project_sidebar.html +99 -0
- taguette/templates/partials/spinner.html +11 -0
- taguette/templates/permissions.html +54 -0
- taguette/templates/project.html +288 -0
- taguette/templates/project_delete.html +17 -0
- taguette/templates/project_import.html +122 -0
- taguette/templates/project_import_codebook.html +34 -0
- taguette/templates/project_import_codebook_confirm.html +49 -0
- taguette/templates/project_new.html +31 -0
- taguette/templates/tos.html +8 -0
- taguette/templates/trans.js +7 -0
- taguette/templates/welcome.html +15 -0
- taguette/utils.py +177 -0
- taguette/validate.py +141 -0
- taguette/version_check.py +102 -0
- taguette/web/__init__.py +252 -0
- taguette/web/api.py +1835 -0
- taguette/web/base.py +864 -0
- taguette/web/export.py +338 -0
- taguette/web/views.py +1618 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{% trans "page title" %}Import codebook{% endtrans %} | LibreQDA{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block contents %}
|
|
6
|
+
<h1>{% trans "title" %}Import a codebook{% endtrans %}</h1>
|
|
7
|
+
<p>{% trans %}Use this form to import an existing codebook into your project. Existing tags will not be deleted, new tags will be added alongside them.{% endtrans %}</p>
|
|
8
|
+
{% for error in errors %}
|
|
9
|
+
<div class="alert alert-danger">{{ error }}</div>
|
|
10
|
+
{% endfor %}
|
|
11
|
+
<p>{% trans %}You are about to create the following tags:{% endtrans %}</p>
|
|
12
|
+
<form action="{{ reverse_url('import_codebook', project.id) }}" method="post">
|
|
13
|
+
{{ xsrf_form_html() }}
|
|
14
|
+
{% for tag in tags %}
|
|
15
|
+
<div class="form-group row">
|
|
16
|
+
<div class="col-sm-2 form-check">
|
|
17
|
+
{% if tag.exists %}
|
|
18
|
+
<input type="checkbox" class="form-check-input" name="tag{{ loop.index0 }}-replace" id="tag{{ loop.index0 }}-replace" aria-label="Replace this tag"{% if tag.enabled %} checked{% endif %} />
|
|
19
|
+
<label for="tag{{ loop.index0 }}-replace">{% trans "codebook import tag option" %}Replace{% endtrans %}</label>
|
|
20
|
+
{% else %}
|
|
21
|
+
<input type="checkbox" class="form-check-input" name="tag{{ loop.index0 }}-import" id="tag{{ loop.index0 }}-import" aria-label="Replace this tag"{% if tag.enabled %} checked{% endif %} />
|
|
22
|
+
<label for="tag{{ loop.index0 }}-import">{% trans "codebook import tag option" %}Import{% endtrans %}</label>
|
|
23
|
+
{% endif %}
|
|
24
|
+
</div>
|
|
25
|
+
<div class="col-sm-3">
|
|
26
|
+
{% if tag.parent == '' %}
|
|
27
|
+
<input type="text" class="form-control" value="{{ tag.path }}" disabled/>
|
|
28
|
+
{% else %}
|
|
29
|
+
<input type="text" class="form-control" value="{{ tag.path }} -> {{ tag.parent }}" disabled/>
|
|
30
|
+
{% endif %}
|
|
31
|
+
<input type="hidden" name="tag{{ loop.index0 }}-path" value="{{ tag.path }}"/>
|
|
32
|
+
<input type="hidden" name="tag{{ loop.index0 }}-parent" value="{{ tag.parent }}"/>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="col-sm-2">
|
|
35
|
+
<input type="color" class="form-control" name="tag{{ loop.index0 }}-color" value="{{ tag.color }}"/>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="col-sm-5">
|
|
38
|
+
<input type="text" class="form-control" name="tag{{ loop.index0 }}-description" value="{{ tag.description }}" />
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
{% endfor %}
|
|
42
|
+
<div class="form-group row">
|
|
43
|
+
<div class="col-sm-10 offset-sm-2">
|
|
44
|
+
<input type="submit" class="btn btn-primary" value="{% trans "form submit button" %}Create tags{% endtrans %}" />
|
|
45
|
+
<a href="{{ reverse_url('project', project.id) }}" class="btn btn-secondary">{% trans "cancel button" %}Go back{% endtrans %}</a>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</form>
|
|
49
|
+
{% endblock %}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block title %}{% trans "page title" %}New project{% endtrans %} | LibreQDA{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block contents %}
|
|
6
|
+
<p>{% trans "title" %}New project{% endtrans %}</p>
|
|
7
|
+
{% if error %}
|
|
8
|
+
<div class="alert alert-danger">{{ error }}</div>
|
|
9
|
+
{% endif %}
|
|
10
|
+
<form action="{{ reverse_url('new_project') }}" method="post">
|
|
11
|
+
{{ xsrf_form_html() }}
|
|
12
|
+
<div class="form-group row">
|
|
13
|
+
<label for="project-name" class="col-sm-2 col-form-label">{% trans "form label for new project name" %}Name{% endtrans %}</label>
|
|
14
|
+
<div class="col-sm-10">
|
|
15
|
+
<input type="text" class="form-control" id="project-name" name="name" value="{{ name }}" required />
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="form-group row">
|
|
19
|
+
<label for="project-description" class="col-sm-2 col-form-label">{% trans "form label for new project description" %}Description{% endtrans %}</label>
|
|
20
|
+
<div class="col-sm-10">
|
|
21
|
+
<textarea class="form-control" id="project-description" name="description">{{ description }}</textarea>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-group row">
|
|
25
|
+
<div class="col-sm-10 offset-sm-2">
|
|
26
|
+
<button type="submit" class="btn btn-primary" data-test-id="save-project-button">{% trans "form submit button" %}Create{% endtrans %}</button>
|
|
27
|
+
<a href="{{ reverse_url('index') }}" class="btn btn-secondary ms-2">{% trans %}Cancel{% endtrans %}</a>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</form>
|
|
31
|
+
{% endblock %}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% set active_page = "index" %}
|
|
3
|
+
|
|
4
|
+
{% block title %}{% trans "page title" %}Welcome{% endtrans %} | LibreQDA{% endblock %}
|
|
5
|
+
|
|
6
|
+
{% block contents %}
|
|
7
|
+
<div class="jumbotron">
|
|
8
|
+
<h1 class="display-3">{% trans "title" %}Welcome{% endtrans %}</h1>
|
|
9
|
+
<p class="lead">{% trans %}<span><a href="https://help.libreqda.org/">LibreQDA</a></span> is an open-source web-based document tagging tool for qualitative data analysis.{% endtrans %}</p>
|
|
10
|
+
<p>{% trans %}Using this tool, you can upload a collection of documents, create a hierarchy of codes, and annotate portions of documents with codes and notes that you can recall and organize later.{% endtrans %}</p>
|
|
11
|
+
{% if register_enabled %}
|
|
12
|
+
<p>{% trans url=reverse_url('register') %}<a href="{{ url }}">Register now</a> for free and get started!{% endtrans %}</p>
|
|
13
|
+
{% endif %}
|
|
14
|
+
</div>
|
|
15
|
+
{% endblock %}
|
taguette/utils.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sentry_sdk
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _f(message):
|
|
12
|
+
"""Pass-through translation function.
|
|
13
|
+
|
|
14
|
+
Marks a string for translation without translating it at run time.
|
|
15
|
+
"""
|
|
16
|
+
return message
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DefaultMap(object):
|
|
20
|
+
def __init__(self, default, mapping):
|
|
21
|
+
self.__default = default
|
|
22
|
+
self.mapping = mapping
|
|
23
|
+
|
|
24
|
+
def get(self, key):
|
|
25
|
+
try:
|
|
26
|
+
return self.mapping[key]
|
|
27
|
+
except KeyError:
|
|
28
|
+
return self.__default(key)
|
|
29
|
+
|
|
30
|
+
def __getitem__(self, key):
|
|
31
|
+
return self.get(key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def log_and_wait_proc(logger, proc):
|
|
35
|
+
while proc.returncode is None:
|
|
36
|
+
line = await proc.stdout.readline()
|
|
37
|
+
if not line:
|
|
38
|
+
break
|
|
39
|
+
line = line.decode('utf-8', 'replace')
|
|
40
|
+
if line[-1] == '\n':
|
|
41
|
+
line = line[:-1]
|
|
42
|
+
logger.info("%d: %s", proc.pid, line)
|
|
43
|
+
|
|
44
|
+
ret = await proc.wait()
|
|
45
|
+
lines = await proc.stdout.read()
|
|
46
|
+
for line in lines.splitlines():
|
|
47
|
+
line = line.decode('utf-8', 'replace')
|
|
48
|
+
logger.info("%d: %s", proc.pid, line)
|
|
49
|
+
return ret
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_windows_device_files = ('CON', 'AUX', 'COM1', 'COM2', 'COM3', 'COM4', 'LPT1',
|
|
53
|
+
'LPT2', 'LPT3', 'PRN', 'NUL')
|
|
54
|
+
_not_ascii_re = re.compile(r'[^A-Za-z0-9_.-]')
|
|
55
|
+
_percent_escapes_re = re.compile(r'%[0-9A-Fa-f]{2}')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sanitize_filename(name):
|
|
59
|
+
"""Sanitize a filename.
|
|
60
|
+
|
|
61
|
+
This takes a filename, for example provided by a browser with a file
|
|
62
|
+
upload, and turn it into something that is safe for opening.
|
|
63
|
+
|
|
64
|
+
Adapted from werkzeug's secure_filename(), copyright 2007 the Pallets team.
|
|
65
|
+
https://palletsprojects.com/p/werkzeug/
|
|
66
|
+
"""
|
|
67
|
+
if not isinstance(name, str):
|
|
68
|
+
raise ValueError("File name is not a string")
|
|
69
|
+
if not name:
|
|
70
|
+
raise ValueError("File name cannot be empty")
|
|
71
|
+
name = _percent_escapes_re.sub('_', name)
|
|
72
|
+
if '/' in name:
|
|
73
|
+
name = name[name.rindex('/') + 1:]
|
|
74
|
+
if sanitize_filename.windows and '\\' in name:
|
|
75
|
+
# It seems that IE gets that wrong, at least when the file is from
|
|
76
|
+
# a network share
|
|
77
|
+
name = name[name.rindex('\\') + 1:]
|
|
78
|
+
name, ext = os.path.splitext(name)
|
|
79
|
+
name = name[:20]
|
|
80
|
+
ext = ext[:8]
|
|
81
|
+
name = _not_ascii_re.sub('', name).strip('._')
|
|
82
|
+
if not name:
|
|
83
|
+
name = '_'
|
|
84
|
+
ext = _not_ascii_re.sub('', ext)
|
|
85
|
+
if (
|
|
86
|
+
sanitize_filename.windows
|
|
87
|
+
and name.split('.')[0].upper() in _windows_device_files
|
|
88
|
+
):
|
|
89
|
+
name = '_' + name
|
|
90
|
+
name = name + ext
|
|
91
|
+
return name
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
sanitize_filename.windows = os.name == 'nt'
|
|
95
|
+
|
|
96
|
+
_background_future_references = {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def background_task(task, *, should_never_exit=False):
|
|
100
|
+
future = asyncio.get_event_loop().create_task(task)
|
|
101
|
+
ident = id(future)
|
|
102
|
+
|
|
103
|
+
def callback(future):
|
|
104
|
+
_background_future_references.pop(ident, None)
|
|
105
|
+
try:
|
|
106
|
+
future.result()
|
|
107
|
+
except Exception as e:
|
|
108
|
+
sentry_sdk.capture_exception(e)
|
|
109
|
+
logger.exception("Exception in background task")
|
|
110
|
+
if should_never_exit:
|
|
111
|
+
logger.critical("Critical task died, exiting")
|
|
112
|
+
asyncio.get_event_loop().stop()
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
future.add_done_callback(callback)
|
|
116
|
+
|
|
117
|
+
# Keep a strong reference to the future
|
|
118
|
+
# https://bugs.python.org/issue21163
|
|
119
|
+
_background_future_references[ident] = future
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_operator_consistency(paths):
|
|
123
|
+
has_and = '∩' in paths
|
|
124
|
+
has_or = '∪' in paths
|
|
125
|
+
|
|
126
|
+
if has_and and has_or:
|
|
127
|
+
return 'mixed'
|
|
128
|
+
elif has_and:
|
|
129
|
+
return 'and'
|
|
130
|
+
elif has_or:
|
|
131
|
+
return 'or'
|
|
132
|
+
else:
|
|
133
|
+
return 'or'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_paths_for_exclusion(paths):
|
|
137
|
+
import re
|
|
138
|
+
exclude_paths = []
|
|
139
|
+
include_paths = []
|
|
140
|
+
for path in re.findall(r'([∩∪¬]?[^\∩∪¬]+)', paths):
|
|
141
|
+
if path.startswith('¬'):
|
|
142
|
+
exclude_paths.append(path[1:])
|
|
143
|
+
else:
|
|
144
|
+
include_paths.append(path)
|
|
145
|
+
include_paths_string = ''.join(include_paths)
|
|
146
|
+
return exclude_paths, include_paths_string
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_path_informations(paths):
|
|
150
|
+
exclude_paths, include_paths_string = parse_paths_for_exclusion(paths)
|
|
151
|
+
|
|
152
|
+
operator_type = check_operator_consistency(paths)
|
|
153
|
+
|
|
154
|
+
list_of_paths = []
|
|
155
|
+
|
|
156
|
+
if operator_type == 'and':
|
|
157
|
+
list_of_paths = include_paths_string.split('∩')
|
|
158
|
+
elif operator_type == 'or':
|
|
159
|
+
list_of_paths = include_paths_string.split('∪')
|
|
160
|
+
elif operator_type == 'mixed':
|
|
161
|
+
list_of_paths = separate_and_group_tags(paths)
|
|
162
|
+
|
|
163
|
+
return list_of_paths, operator_type, exclude_paths
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def separate_and_group_tags(paths):
|
|
167
|
+
|
|
168
|
+
exclude_paths, include_paths_string = parse_paths_for_exclusion(paths)
|
|
169
|
+
|
|
170
|
+
or_groups = include_paths_string.split('∪')
|
|
171
|
+
|
|
172
|
+
grouped_tags = []
|
|
173
|
+
for group in or_groups:
|
|
174
|
+
and_tags = group.split('∩')
|
|
175
|
+
grouped_tags.append(and_tags)
|
|
176
|
+
|
|
177
|
+
return grouped_tags
|
taguette/validate.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import string
|
|
3
|
+
|
|
4
|
+
from .utils import _f
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvalidFormat(ValueError):
|
|
8
|
+
"""User-supplied value doesn't pass validation."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message):
|
|
11
|
+
super(InvalidFormat, self).__init__(message)
|
|
12
|
+
self.message = message
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def description(descr):
|
|
16
|
+
if not isinstance(descr, str):
|
|
17
|
+
raise ValueError("Description is not a string")
|
|
18
|
+
if len(descr) > 102400:
|
|
19
|
+
raise InvalidFormat(_f("Description is too long"))
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def color(value):
|
|
24
|
+
if not isinstance(value, str):
|
|
25
|
+
raise ValueError("color is not a string")
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def project_name(name):
|
|
30
|
+
if not name:
|
|
31
|
+
raise InvalidFormat(_f("Project name cannot be empty"))
|
|
32
|
+
if len(name) > 50:
|
|
33
|
+
raise InvalidFormat(_f("Project name is too long"))
|
|
34
|
+
return True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
ALLOWED_LOGIN_CHARACTERS_NEW = (
|
|
38
|
+
string.ascii_lowercase + string.digits +
|
|
39
|
+
'._-'
|
|
40
|
+
)
|
|
41
|
+
ALLOWED_LOGIN_CHARACTERS = (
|
|
42
|
+
ALLOWED_LOGIN_CHARACTERS_NEW + '+@ '
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fix_user_login(login, *, new=False):
|
|
47
|
+
login = login.lower()
|
|
48
|
+
user_login(login, new=new)
|
|
49
|
+
return login
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def user_login(login, *, new=False):
|
|
53
|
+
if not isinstance(login, str):
|
|
54
|
+
raise ValueError("Login is not a string")
|
|
55
|
+
if not login:
|
|
56
|
+
raise InvalidFormat(_f("User login cannot be empty"))
|
|
57
|
+
if len(login) > 255:
|
|
58
|
+
raise InvalidFormat(_f("User login is too long"))
|
|
59
|
+
if login != login.lower():
|
|
60
|
+
raise InvalidFormat(_f("User login is not lowercase"))
|
|
61
|
+
chars = ALLOWED_LOGIN_CHARACTERS_NEW if new else ALLOWED_LOGIN_CHARACTERS
|
|
62
|
+
if any(c not in chars for c in login):
|
|
63
|
+
raise InvalidFormat(_f("User login contains forbidden characters"))
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def user_email(email):
|
|
68
|
+
if not isinstance(email, str):
|
|
69
|
+
raise ValueError("Email is not a string")
|
|
70
|
+
if not email:
|
|
71
|
+
raise InvalidFormat(_f("Email cannot be empty")) # but it can be NULL
|
|
72
|
+
if '@' not in email:
|
|
73
|
+
raise InvalidFormat(_f("Invalid email address"))
|
|
74
|
+
if len(email) > 256:
|
|
75
|
+
raise InvalidFormat(_f("Email address is too long"))
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def user_password(password):
|
|
80
|
+
if not isinstance(password, str):
|
|
81
|
+
raise ValueError("Password is not a string")
|
|
82
|
+
if len(password) < 5:
|
|
83
|
+
raise InvalidFormat(_f("Please use a longer password"))
|
|
84
|
+
if len(password) > 5120:
|
|
85
|
+
raise InvalidFormat(_f("Please use a shorter password"))
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def document_name(name):
|
|
90
|
+
if not isinstance(name, str):
|
|
91
|
+
raise ValueError("Document name is not a string")
|
|
92
|
+
if not name:
|
|
93
|
+
raise InvalidFormat(_f("Document name cannot be empty"))
|
|
94
|
+
if len(name) > 50:
|
|
95
|
+
raise InvalidFormat(_f("Document name is too long"))
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tag_path(path):
|
|
100
|
+
if not isinstance(path, str):
|
|
101
|
+
raise ValueError("Tag path is not a string")
|
|
102
|
+
if not path:
|
|
103
|
+
raise InvalidFormat(_f("Tag path cannot be empty"))
|
|
104
|
+
if len(path) > 200:
|
|
105
|
+
raise InvalidFormat(_f("Tag path is too long"))
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def filename(name):
|
|
110
|
+
if not isinstance(name, str):
|
|
111
|
+
raise ValueError("File name is not a string")
|
|
112
|
+
if not name:
|
|
113
|
+
raise ValueError("File name cannot be empty")
|
|
114
|
+
if len(name) > 100:
|
|
115
|
+
raise InvalidFormat(_f("File name is too long"))
|
|
116
|
+
if '\x00' in name:
|
|
117
|
+
raise InvalidFormat(_f("Invalid file name"))
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fix_filename(name):
|
|
122
|
+
if not isinstance(name, str):
|
|
123
|
+
raise ValueError("File name is not a string")
|
|
124
|
+
if not name:
|
|
125
|
+
raise ValueError("File name cannot be empty")
|
|
126
|
+
if '/' in name:
|
|
127
|
+
name = name[name.rindex('/') + 1:]
|
|
128
|
+
if fix_filename.windows and '\\' in name:
|
|
129
|
+
# It seems that IE gets that wrong, at least when the file is from
|
|
130
|
+
# a network share
|
|
131
|
+
name = name[name.rindex('\\') + 1:]
|
|
132
|
+
name, ext = os.path.splitext(name)
|
|
133
|
+
name = name[:50]
|
|
134
|
+
ext = ext[:8]
|
|
135
|
+
if not name:
|
|
136
|
+
name = '_'
|
|
137
|
+
name = name + ext
|
|
138
|
+
return name
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
fix_filename.windows = os.name == 'nt'
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from tornado.httpclient import AsyncHTTPClient
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .utils import background_task
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
GITLAB_TAGS_URL = (
|
|
18
|
+
'https://gitlab.com/api/v4/projects/'
|
|
19
|
+
'bin-cirst%2Flibreqda%2Flibre-qda/repository/tags'
|
|
20
|
+
'?per_page=20&order_by=updated&sort=desc'
|
|
21
|
+
)
|
|
22
|
+
CACHE_TTL = 6 * 3600
|
|
23
|
+
|
|
24
|
+
_VERSION_RE = re.compile(r'(\d+(?:\.\d+)*)')
|
|
25
|
+
|
|
26
|
+
_cache = {
|
|
27
|
+
'latest': None,
|
|
28
|
+
'checked_at': 0.0,
|
|
29
|
+
'in_flight': False,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse(version):
|
|
34
|
+
match = _VERSION_RE.search(version or '')
|
|
35
|
+
if not match:
|
|
36
|
+
return ()
|
|
37
|
+
return tuple(int(x) for x in match.group(1).split('.'))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_newer(candidate, current):
|
|
41
|
+
try:
|
|
42
|
+
return _parse(candidate) > _parse(current)
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _fetch_latest():
|
|
48
|
+
try:
|
|
49
|
+
client = AsyncHTTPClient()
|
|
50
|
+
response = await client.fetch(
|
|
51
|
+
GITLAB_TAGS_URL,
|
|
52
|
+
request_timeout=5,
|
|
53
|
+
connect_timeout=5,
|
|
54
|
+
)
|
|
55
|
+
data = json.loads(response.body)
|
|
56
|
+
if data and isinstance(data, list):
|
|
57
|
+
latest = None
|
|
58
|
+
latest_parsed = ()
|
|
59
|
+
for tag in data:
|
|
60
|
+
name = tag.get('name') or ''
|
|
61
|
+
parsed = _parse(name)
|
|
62
|
+
if parsed and parsed > latest_parsed:
|
|
63
|
+
latest_parsed = parsed
|
|
64
|
+
latest = name
|
|
65
|
+
if latest:
|
|
66
|
+
_cache['latest'] = latest
|
|
67
|
+
logger.info("Latest LibreQDA tag: %s", latest)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.warning("Version check failed: %s", e)
|
|
70
|
+
finally:
|
|
71
|
+
_cache['checked_at'] = time.time()
|
|
72
|
+
_cache['in_flight'] = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def is_desktop_bundle():
|
|
76
|
+
return getattr(sys, 'frozen', False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_outdated_version():
|
|
80
|
+
forced = os.environ.get('LIBREQDA_FORCE_OUTDATED_VERSION')
|
|
81
|
+
if forced:
|
|
82
|
+
return forced
|
|
83
|
+
|
|
84
|
+
if not is_desktop_bundle():
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
now = time.time()
|
|
88
|
+
if (
|
|
89
|
+
not _cache['in_flight']
|
|
90
|
+
and now - _cache['checked_at'] > CACHE_TTL
|
|
91
|
+
):
|
|
92
|
+
_cache['in_flight'] = True
|
|
93
|
+
try:
|
|
94
|
+
background_task(_fetch_latest())
|
|
95
|
+
except RuntimeError:
|
|
96
|
+
_cache['in_flight'] = False
|
|
97
|
+
|
|
98
|
+
latest = _cache['latest']
|
|
99
|
+
if latest and _is_newer(latest, __version__):
|
|
100
|
+
match = _VERSION_RE.search(latest)
|
|
101
|
+
return match.group(1) if match else latest
|
|
102
|
+
return None
|