diff --git a/.env.tpl b/.env.tpl index 30a89416..b63e2920 100644 --- a/.env.tpl +++ b/.env.tpl @@ -57,6 +57,18 @@ HOST_DOCKER_GID= # ASSETS_DEBUG= +################################################################################ +# Flask-Hashids # +# https://github.com/Pevtrick/Flask-Hashids # +################################################################################ +# DEFAULT: 16 +# HASHIDS_MIN_LENGTH= + +# NOTE: Use this bash command `python -c "import uuid; print(uuid.uuid4().hex)"` +# It is strongly recommended that this is NEVER the same as the SECRET_KEY +HASHIDS_SALT= + + ################################################################################ # Flask-Login # # https://flask-login.readthedocs.io/en/latest/ # diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..ecf868e0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "samuelcolvin.jinjahtml", + "ms-azuretools.vscode-docker", + "ms-python.python" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/Dockerfile b/Dockerfile index 8ccdf7f6..fe63463e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.15-slim-bullseye +FROM python:3.8.10-slim-buster LABEL authors="Patrick Jentsch " diff --git a/app/SpaCyNLPPipelineModel.defaults.yml b/app/SpaCyNLPPipelineModel.defaults.yml index f8f3116b..62dc5e65 100644 --- a/app/SpaCyNLPPipelineModel.defaults.yml +++ b/app/SpaCyNLPPipelineModel.defaults.yml @@ -120,6 +120,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'German' description: 'German pipeline optimized for CPU. Components: tok2vec, tagger, morphologizer, parser, lemmatizer (trainable_lemmatizer), senter, ner.' url: 'https://github.com/explosion/spacy-models/releases/download/de_core_news_md-3.4.0/de_core_news_md-3.4.0.tar.gz' @@ -131,6 +132,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'Greek' description: 'Greek pipeline optimized for CPU. Components: tok2vec, morphologizer, parser, lemmatizer (trainable_lemmatizer), senter, ner, attribute_ruler.' url: 'https://github.com/explosion/spacy-models/releases/download/el_core_news_md-3.4.0/el_core_news_md-3.4.0.tar.gz' @@ -142,6 +144,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'English' description: 'English pipeline optimized for CPU. Components: tok2vec, tagger, parser, senter, ner, attribute_ruler, lemmatizer.' url: 'https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.4.1/en_core_web_md-3.4.1.tar.gz' @@ -153,6 +156,7 @@ version: '3.4.1' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'Spanish' description: 'Spanish pipeline optimized for CPU. Components: tok2vec, morphologizer, parser, senter, ner, attribute_ruler, lemmatizer.' url: 'https://github.com/explosion/spacy-models/releases/download/es_core_news_md-3.4.0/es_core_news_md-3.4.0.tar.gz' @@ -164,6 +168,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'French' description: 'French pipeline optimized for CPU. Components: tok2vec, morphologizer, parser, senter, ner, attribute_ruler, lemmatizer.' url: 'https://github.com/explosion/spacy-models/releases/download/fr_core_news_md-3.4.0/fr_core_news_md-3.4.0.tar.gz' @@ -175,6 +180,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'Italian' description: 'Italian pipeline optimized for CPU. Components: tok2vec, morphologizer, tagger, parser, lemmatizer (trainable_lemmatizer), senter, ner' url: 'https://github.com/explosion/spacy-models/releases/download/it_core_news_md-3.4.0/it_core_news_md-3.4.0.tar.gz' @@ -186,6 +192,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'Polish' description: 'Polish pipeline optimized for CPU. Components: tok2vec, morphologizer, parser, lemmatizer (trainable_lemmatizer), tagger, senter, ner.' url: 'https://github.com/explosion/spacy-models/releases/download/pl_core_news_md-3.4.0/pl_core_news_md-3.4.0.tar.gz' @@ -197,6 +204,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'Russian' description: 'Russian pipeline optimized for CPU. Components: tok2vec, morphologizer, parser, senter, ner, attribute_ruler, lemmatizer.' url: 'https://github.com/explosion/spacy-models/releases/download/ru_core_news_md-3.4.0/ru_core_news_md-3.4.0.tar.gz' @@ -208,6 +216,7 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' - title: 'Chinese' description: 'Chinese pipeline optimized for CPU. Components: tok2vec, tagger, parser, senter, ner, attribute_ruler.' url: 'https://github.com/explosion/spacy-models/releases/download/zh_core_web_md-3.4.0/zh_core_web_md-3.4.0.tar.gz' @@ -219,3 +228,4 @@ version: '3.4.0' compatible_service_versions: - '0.1.1' + - '0.1.2' diff --git a/app/__init__.py b/app/__init__.py index 78e208d1..cc747a89 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -87,7 +87,4 @@ def create_app(config: Config = Config) -> Flask: from .users import bp as users_blueprint app.register_blueprint(users_blueprint, url_prefix='/users') - from .test import bp as test_blueprint - app.register_blueprint(test_blueprint, url_prefix='/test') - return app diff --git a/app/admin/routes.py b/app/admin/routes.py index c4480e18..08f219ab 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -5,9 +5,9 @@ from app import db, hashids from app.decorators import admin_required from app.models import Role, User, UserSettingJobStatusMailNotificationLevel from app.settings.forms import ( - EditGeneralSettingsForm, EditNotificationSettingsForm ) +from app.users.forms import EditProfileSettingsForm from . import bp from .forms import AdminEditUserForm @@ -30,10 +30,10 @@ def index(): @bp.route('/users') def users(): - json_users = [x.to_json_serializeable(backrefs=True) for x in User.query.all()] + users = [x.to_json_serializeable(backrefs=True) for x in User.query.all()] return render_template( 'admin/users.html.j2', - json_users=json_users, + users=users, title='Users' ) @@ -51,10 +51,10 @@ def edit_user(user_id): data={'confirmed': user.confirmed, 'role': user.role.hashid}, prefix='admin-edit-user-form' ) - edit_general_settings_form = EditGeneralSettingsForm( + edit_profile_settings_form = EditProfileSettingsForm( user, data=user.to_json_serializeable(), - prefix='edit-general-settings-form' + prefix='edit-profile-settings-form' ) edit_notification_settings_form = EditNotificationSettingsForm( data=user.to_json_serializeable(), @@ -68,10 +68,10 @@ def edit_user(user_id): db.session.commit() flash('Your changes have been saved') return redirect(url_for('.edit_user', user_id=user.id)) - if (edit_general_settings_form.submit.data - and edit_general_settings_form.validate()): - user.email = edit_general_settings_form.email.data - user.username = edit_general_settings_form.username.data + if (edit_profile_settings_form.submit.data + and edit_profile_settings_form.validate()): + user.email = edit_profile_settings_form.email.data + user.username = edit_profile_settings_form.username.data db.session.commit() flash('Your changes have been saved') return redirect(url_for('.edit_user', user_id=user.id)) @@ -87,7 +87,7 @@ def edit_user(user_id): return render_template( 'admin/edit_user.html.j2', admin_edit_user_form=admin_edit_user_form, - edit_general_settings_form=edit_general_settings_form, + edit_profile_settings_form=edit_profile_settings_form, edit_notification_settings_form=edit_notification_settings_form, title='Edit user', user=user diff --git a/app/api/schemas.py b/app/api/schemas.py index 7abb56de..f0792f7c 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -66,7 +66,7 @@ class TesseractOCRPipelineModelSchema(ma.SQLAlchemySchema): publishing_year = ma.Int( required=True ) - shared = ma.Boolean(required=True) + is_public = ma.Boolean(required=True) class JobSchema(ma.SQLAlchemySchema): diff --git a/app/contributions/forms.py b/app/contributions/forms.py index 0ba8f5d5..eb25babb 100644 --- a/app/contributions/forms.py +++ b/app/contributions/forms.py @@ -55,7 +55,6 @@ class CreateTesseractOCRPipelineModelForm(ContributionBaseForm): ) def validate_tesseract_model_file(self, field): - current_app.logger.warning(field.data.filename) if not field.data.filename.lower().endswith('.traineddata'): raise ValidationError('traineddata files only!') @@ -80,7 +79,6 @@ class CreateSpaCyNLPPipelineModelForm(ContributionBaseForm): ) def validate_spacy_model_file(self, field): - current_app.logger.warning(field.data.filename) if not field.data.filename.lower().endswith('.tar.gz'): raise ValidationError('.tar.gz files only!') diff --git a/app/contributions/routes.py b/app/contributions/routes.py index 40b21203..3bc37eb8 100644 --- a/app/contributions/routes.py +++ b/app/contributions/routes.py @@ -104,7 +104,7 @@ def create_tesseract_ocr_pipeline_model(): publisher_url=form.publisher_url.data, publishing_url=form.publishing_url.data, publishing_year=form.publishing_year.data, - shared=False, + is_public=False, title=form.title.data, version=form.version.data, user=current_user @@ -131,7 +131,7 @@ def toggle_tesseract_ocr_pipeline_model_public_status(tesseract_ocr_pipeline_mod tesseract_ocr_pipeline_model = TesseractOCRPipelineModel.query.get_or_404(tesseract_ocr_pipeline_model_id) if not (tesseract_ocr_pipeline_model.user == current_user or current_user.is_administrator()): abort(403) - tesseract_ocr_pipeline_model.shared = not tesseract_ocr_pipeline_model.shared + tesseract_ocr_pipeline_model.is_public = not tesseract_ocr_pipeline_model.is_public db.session.commit() return {}, 201 @@ -201,7 +201,7 @@ def create_spacy_nlp_pipeline_model(): publisher_url=form.publisher_url.data, publishing_url=form.publishing_url.data, publishing_year=form.publishing_year.data, - shared=False, + is_public=False, title=form.title.data, version=form.version.data, user=current_user @@ -228,6 +228,6 @@ def toggle_spacy_nlp_pipeline_model_public_status(spacy_nlp_pipeline_model_id): spacy_nlp_pipeline_model = SpaCyNLPPipelineModel.query.get_or_404(spacy_nlp_pipeline_model_id) if not (spacy_nlp_pipeline_model.user == current_user or current_user.is_administrator()): abort(403) - spacy_nlp_pipeline_model.shared = not spacy_nlp_pipeline_model.shared + spacy_nlp_pipeline_model.is_public = not spacy_nlp_pipeline_model.is_public db.session.commit() return {}, 201 diff --git a/app/corpora/cqi_over_socketio/cqi_corpora_corpus_subcorpora.py b/app/corpora/cqi_over_socketio/cqi_corpora_corpus_subcorpora.py index 3c8cbdd4..578968be 100644 --- a/app/corpora/cqi_over_socketio/cqi_corpora_corpus_subcorpora.py +++ b/app/corpora/cqi_over_socketio/cqi_corpora_corpus_subcorpora.py @@ -1,13 +1,9 @@ -from flask import session import cqi -import json import math -import os from app import socketio from app.decorators import socketio_login_required -from app.models import Corpus from . import NAMESPACE as ns -from .utils import cqi_over_socketio, export_subcorpus +from .utils import cqi_over_socketio, export_subcorpus, partial_export_subcorpus @socketio.on('cqi.corpora.corpus.subcorpora.get', namespace=ns) @@ -109,6 +105,16 @@ def cqi_corpora_corpus_subcorpora_subcorpus_paginate(cqi_client: cqi.CQiClient, return {'code': 200, 'msg': 'OK', 'payload': payload} +@socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.partial_export', namespace=ns) +@socketio_login_required +@cqi_over_socketio +def cqi_corpora_corpus_subcorpora_subcorpus_partial_export(cqi_client: cqi.CQiClient, corpus_name: str, subcorpus_name: str, match_id_list: list, context: int = 50): # noqa + cqi_corpus = cqi_client.corpora.get(corpus_name) + cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) + cqi_subcorpus_partial_export = partial_export_subcorpus(cqi_subcorpus, match_id_list, context=context) + return {'code': 200, 'msg': 'OK', 'payload': cqi_subcorpus_partial_export} + + @socketio.on('cqi.corpora.corpus.subcorpora.subcorpus.export', namespace=ns) @socketio_login_required @cqi_over_socketio @@ -116,8 +122,4 @@ def cqi_corpora_corpus_subcorpora_subcorpus_export(cqi_client: cqi.CQiClient, co cqi_corpus = cqi_client.corpora.get(corpus_name) cqi_subcorpus = cqi_corpus.subcorpora.get(subcorpus_name) cqi_subcorpus_export = export_subcorpus(cqi_subcorpus, context=context) - corpus = Corpus.query.get(session['d']['corpus_id']) - file_path = os.path.join(corpus.path, f'{subcorpus_name}.json') - with open(file_path, 'w') as file: - json.dump(cqi_subcorpus_export, file) - return {'code': 200, 'msg': 'OK'} + return {'code': 200, 'msg': 'OK', 'payload': cqi_subcorpus_export} diff --git a/app/corpora/cqi_over_socketio/utils.py b/app/corpora/cqi_over_socketio/utils.py index 9763548a..bdab8b53 100644 --- a/app/corpora/cqi_over_socketio/utils.py +++ b/app/corpora/cqi_over_socketio/utils.py @@ -68,7 +68,7 @@ def lookups_by_cpos(corpus, cpos_list): cpos_attr_values[i] for attr in corpus.structural_attributes.list(): # We only want to iterate over non subattributes, identifiable by - # attr.attrs['has_values']==False + # attr.attrs['has_values'] == False if attr.attrs['has_values']: continue cpos_attr_ids = attr.ids_by_cpos(cpos_list) @@ -93,43 +93,86 @@ def lookups_by_cpos(corpus, cpos_list): return lookups +def partial_export_subcorpus(subcorpus, match_id_list, context=25): + if subcorpus.attrs['size'] == 0: + return {"matches": []} + match_boundaries = [] + for match_id in match_id_list: + if match_id < 0 or match_id >= subcorpus.attrs['size']: + continue + match_boundaries.append( + ( + match_id, + subcorpus.dump(subcorpus.attrs['fields']['match'], match_id, match_id)[0], + subcorpus.dump(subcorpus.attrs['fields']['matchend'], match_id, match_id)[0] + ) + ) + cpos_set = set() + matches = [] + for match_boundary in match_boundaries: + match_num, match_start, match_end = match_boundary + c = (match_start, match_end) + if match_start == 0 or context == 0: + lc = None + cpos_list_lbound = match_start + else: + lc_lbound = max(0, (match_start - context)) + lc_rbound = match_start - 1 + lc = (lc_lbound, lc_rbound) + cpos_list_lbound = lc_lbound + if match_end == (subcorpus.collection.corpus.attrs['size'] - 1) or context == 0: + rc = None + cpos_list_rbound = match_end + else: + rc_lbound = match_end + 1 + rc_rbound = min( + (match_end + context), + (subcorpus.collection.corpus.attrs['size'] - 1) + ) + rc = (rc_lbound, rc_rbound) + cpos_list_rbound = rc_rbound + match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc} + matches.append(match) + cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1)) + lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set)) + return {'matches': matches, **lookups} + + def export_subcorpus(subcorpus, context=25, cutoff=float('inf'), offset=0): if subcorpus.attrs['size'] == 0: return {"matches": []} first_match = max(0, offset) last_match = min((offset + cutoff - 1), (subcorpus.attrs['size'] - 1)) match_boundaries = zip( - subcorpus.dump( - subcorpus.attrs['fields']['match'], first_match, last_match), - subcorpus.dump( - subcorpus.attrs['fields']['matchend'], first_match, last_match) + list(range(first_match, last_match + 1)), + subcorpus.dump(subcorpus.attrs['fields']['match'], first_match, last_match), + subcorpus.dump(subcorpus.attrs['fields']['matchend'], first_match, last_match) ) cpos_set = set() matches = [] - match_num = offset + 1 - for match_start, match_end in match_boundaries: + for match_num, match_start, match_end in match_boundaries: c = (match_start, match_end) if match_start == 0 or context == 0: lc = None cpos_list_lbound = match_start else: - lc_lbound = max(0, (match_start - 1 - context)) + lc_lbound = max(0, (match_start - context)) lc_rbound = match_start - 1 lc = (lc_lbound, lc_rbound) cpos_list_lbound = lc_lbound - if (match_end == (subcorpus.collection.corpus.attrs['size'] - 1) - or context == 0): + if match_end == (subcorpus.collection.corpus.attrs['size'] - 1) or context == 0: rc = None cpos_list_rbound = match_end else: rc_lbound = match_end + 1 - rc_rbound = min(match_end + 1 + context, - subcorpus.collection.corpus.attrs['size'] - 1) + rc_rbound = min( + (match_end + context), + (subcorpus.collection.corpus.attrs['size'] - 1) + ) rc = (rc_lbound, rc_rbound) cpos_list_rbound = rc_rbound match = {'num': match_num, 'lc': lc, 'c': c, 'rc': rc} matches.append(match) cpos_set.update(range(cpos_list_lbound, cpos_list_rbound + 1)) - match_num += 1 lookups = lookups_by_cpos(subcorpus.collection.corpus, list(cpos_set)) return {'matches': matches, **lookups} diff --git a/app/corpora/forms.py b/app/corpora/forms.py index db46b0ad..12ec1d9c 100644 --- a/app/corpora/forms.py +++ b/app/corpora/forms.py @@ -1,11 +1,17 @@ from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileRequired -from wtforms import StringField, SubmitField, ValidationError, IntegerField +from wtforms import ( + StringField, + SubmitField, + TextAreaField, + ValidationError, + IntegerField +) from wtforms.validators import InputRequired, Length -class CreateCorpusForm(FlaskForm): - description = StringField( +class CorpusBaseForm(FlaskForm): + description = TextAreaField( 'Description', validators=[InputRequired(), Length(max=255)] ) @@ -13,6 +19,20 @@ class CreateCorpusForm(FlaskForm): submit = SubmitField() +class CreateCorpusForm(CorpusBaseForm): + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-corpus-form' + super().__init__(*args, **kwargs) + + +class UpdateCorpusForm(CorpusBaseForm): + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-corpus-form' + super().__init__(*args, **kwargs) + + class CorpusFileBaseForm(FlaskForm): author = StringField( 'Author', @@ -41,13 +61,21 @@ class CorpusFileBaseForm(FlaskForm): class CreateCorpusFileForm(CorpusFileBaseForm): vrt = FileField('File', validators=[FileRequired()]) + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'create-corpus-file-form' + super().__init__(*args, **kwargs) + def validate_vrt(self, field): if not field.data.filename.lower().endswith('.vrt'): raise ValidationError('VRT files only!') -class EditCorpusFileForm(CorpusFileBaseForm): - pass +class UpdateCorpusFileForm(CorpusFileBaseForm): + def __init__(self, *args, **kwargs): + if 'prefix' not in kwargs: + kwargs['prefix'] = 'update-corpus-file-form' + super().__init__(*args, **kwargs) class ImportCorpusForm(FlaskForm): diff --git a/app/corpora/routes.py b/app/corpora/routes.py index 2e9047c3..f3cdf8f1 100644 --- a/app/corpora/routes.py +++ b/app/corpora/routes.py @@ -13,13 +13,35 @@ import os from app import db from app.models import Corpus, CorpusFile, CorpusStatus from . import bp -from .forms import CreateCorpusFileForm, CreateCorpusForm, EditCorpusFileForm +from .forms import CreateCorpusFileForm, CreateCorpusForm, UpdateCorpusFileForm + + +def user_can_read_corpus(user, corpus): + return corpus.user == user or user.is_administrator() or corpus.is_public + + +def user_can_update_corpus(user, corpus): + return corpus.user == user or user.is_administrator() + + +def user_can_delete_corpus(user, corpus): + return user_can_update_corpus(user, corpus) + + +@bp.route('') +@login_required +def corpora(): + query = Corpus.query.filter( + (Corpus.user_id == current_user.id) | (Corpus.is_public == True) + ) + corpora = [c.to_json_serializeable() for c in query.all()] + return render_template('corpora/corpora.html.j2', corpora=corpora, title='Corpora') @bp.route('/create', methods=['GET', 'POST']) @login_required def create_corpus(): - form = CreateCorpusForm(prefix='create-corpus-form') + form = CreateCorpusForm() if form.validate_on_submit(): try: corpus = Corpus.create( @@ -46,7 +68,7 @@ def create_corpus(): @login_required def corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): + if not user_can_read_corpus(current_user, corpus): abort(403) return render_template( 'corpora/corpus.html.j2', @@ -55,6 +77,19 @@ def corpus(corpus_id): ) +# @bp.route('//update') +# @login_required +# def update_corpus(corpus_id): +# corpus = Corpus.query.get_or_404(corpus_id) +# if not user_can_update_corpus(current_user, corpus): +# abort(403) +# return render_template( +# 'corpora/update_corpus.html.j2', +# corpus=corpus, +# title='Corpus' +# ) + + @bp.route('/', methods=['DELETE']) @login_required def delete_corpus(corpus_id): @@ -65,7 +100,7 @@ def delete_corpus(corpus_id): db.session.commit() corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): + if not user_can_delete_corpus(current_user, corpus): abort(403) thread = Thread( target=_delete_corpus, @@ -79,6 +114,8 @@ def delete_corpus(corpus_id): @login_required def analyse_corpus(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) + if not user_can_read_corpus(current_user, corpus): + abort(403) return render_template( 'corpora/analyse_corpus.html.j2', corpus=corpus, @@ -96,7 +133,7 @@ def build_corpus(corpus_id): db.session.commit() corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): + if not user_can_update_corpus(current_user, corpus): abort(403) # Check if the corpus has corpus files if not corpus.files.all(): @@ -114,9 +151,9 @@ def build_corpus(corpus_id): @login_required def create_corpus_file(corpus_id): corpus = Corpus.query.get_or_404(corpus_id) - if not (corpus.user == current_user or current_user.is_administrator()): + if not user_can_update_corpus(current_user, corpus): abort(403) - form = CreateCorpusFileForm(prefix='create-corpus-file-form') + form = CreateCorpusFileForm() if form.is_submitted(): if not form.validate(): response = {'errors': form.errors} @@ -157,19 +194,13 @@ def create_corpus_file(corpus_id): ) -@bp.route('//files/', - methods=['GET', 'POST']) +@bp.route('//files/', methods=['GET', 'POST']) @login_required def corpus_file(corpus_id, corpus_file_id): - corpus_file = CorpusFile.query.get_or_404(corpus_file_id) - if corpus_file.corpus.id != corpus_id: - abort(404) + corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404() if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) - form = EditCorpusFileForm( - data=corpus_file.to_json_serializeable(), - prefix='edit-corpus-file-form' - ) + form = UpdateCorpusFileForm(data=corpus_file.to_json_serializeable()) if form.validate_on_submit(): form.populate_obj(corpus_file) if db.session.is_modified(corpus_file): @@ -196,9 +227,7 @@ def delete_corpus_file(corpus_id, corpus_file_id): corpus_file.delete() db.session.commit() - corpus_file = CorpusFile.query.get_or_404(corpus_file_id) - if corpus_file.corpus.id != corpus_id: - abort(404) + corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404() if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) thread = Thread( @@ -212,9 +241,7 @@ def delete_corpus_file(corpus_id, corpus_file_id): @bp.route('//files//download') @login_required def download_corpus_file(corpus_id, corpus_file_id): - corpus_file = CorpusFile.query.get_or_404(corpus_file_id) - if corpus_file.corpus.id != corpus_id: - abort(404) + corpus_file = CorpusFile.query.filter_by(corpus_id = corpus_id, id=corpus_file_id).first_or_404() if not (corpus_file.corpus.user == current_user or current_user.is_administrator()): abort(403) return send_from_directory( diff --git a/app/main/routes.py b/app/main/routes.py index 1e7665a3..9935c479 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -1,5 +1,5 @@ from flask import flash, redirect, render_template, url_for -from flask_login import login_required, login_user +from flask_login import current_user, login_required, login_user from app.auth.forms import LoginForm from app.models import User from . import bp @@ -27,7 +27,17 @@ def faq(): @bp.route('/dashboard') @login_required def dashboard(): - return render_template('main/dashboard.html.j2', title='Dashboard') + users = [ + u.to_json_serializeable(filter_by_privacy_settings=True) for u + in User.query.filter(User.is_public == True, User.id != current_user.id).all() + ] + return render_template('main/dashboard.html.j2', title='Dashboard', users=users) + + +@bp.route('/dashboard2') +@login_required +def dashboard2(): + return render_template('main/dashboard2.html.j2', title='Dashboard') @bp.route('/user_manual') diff --git a/app/models.py b/app/models.py index c607dc5f..af3d1cfc 100644 --- a/app/models.py +++ b/app/models.py @@ -3,6 +3,7 @@ from enum import Enum, IntEnum from flask import current_app, url_for from flask_hashids import HashidMixin from flask_login import UserMixin +from sqlalchemy.ext.associationproxy import association_proxy from time import sleep from tqdm import tqdm from werkzeug.security import generate_password_hash, check_password_hash @@ -61,6 +62,12 @@ class UserSettingJobStatusMailNotificationLevel(IntEnum): NONE = 1 END = 2 ALL = 3 + + +class ProfilePrivacySettings(IntEnum): + SHOW_EMAIL = 1 + SHOW_LAST_SEEN = 2 + SHOW_MEMBER_SINCE = 4 # endregion enums @@ -121,6 +128,8 @@ class IntEnumColumn(db.TypeDecorator): return value.value elif isinstance(value, int): return self.enum_type(value).value + elif isinstance(value, str): + return self.enum_type[value].value else: return TypeError() @@ -138,8 +147,7 @@ class ContainerColumn(db.TypeDecorator): def process_bind_param(self, value, dialect): if isinstance(value, self.container_type): return json.dumps(value) - elif (isinstance(value, str) - and isinstance(json.loads(value), self.container_type)): + elif isinstance(value, str) and isinstance(json.loads(value), self.container_type): return value else: return TypeError() @@ -162,7 +170,7 @@ class Role(HashidMixin, db.Model): default = db.Column(db.Boolean, default=False, index=True) permissions = db.Column(db.Integer, default=0) # Relationships - users = db.relationship('User', backref='role', lazy='dynamic') + users = db.relationship('User', back_populates='role', lazy='dynamic') def __repr__(self): return f'' @@ -232,7 +240,8 @@ class Token(db.Model): access_expiration = db.Column(db.DateTime) refresh_token = db.Column(db.String(64), index=True) refresh_expiration = db.Column(db.DateTime) - # Backrefs: user: User + # Relationships + user = db.relationship('User', back_populates='tokens') def expire(self): self.access_expiration = datetime.utcnow() @@ -245,6 +254,51 @@ class Token(db.Model): Token.query.filter(Token.refresh_expiration < yesterday).delete() +class Avatar(HashidMixin, FileMixin, db.Model): + __tablename__ = 'avatars' + # Primary key + id = db.Column(db.Integer, primary_key=True) + # Foreign keys + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + # Relationships + user = db.relationship('User', back_populates='avatar') + + @property + def path(self): + return os.path.join(self.user.path, 'avatar') + + def delete(self): + try: + os.remove(self.path) + except OSError as e: + current_app.logger.error(e) + db.session.delete(self) + + def to_json_serializeable(self, backrefs=False, relationships=False): + json_serializeable = { + 'id': self.hashid, + **self.file_mixin_to_json_serializeable() + } + return json_serializeable + + +class CorpusFollowerAssociation(db.Model): + __tablename__ = 'corpus_follower_associations' + # Primary key + id = db.Column(db.Integer, primary_key=True) + # Foreign keys + following_user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + followed_corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) + # Fields + permissions = db.Column(db.Integer, default=0, nullable=False) + # Relationships + followed_corpus = db.relationship('Corpus', back_populates='following_user_associations') + following_user = db.relationship('User', back_populates='followed_corpus_associations') + + def __repr__(self): + return f'' + + class User(HashidMixin, UserMixin, db.Model): __tablename__ = 'users' # Primary key @@ -262,39 +316,64 @@ class User(HashidMixin, UserMixin, db.Model): default=UserSettingJobStatusMailNotificationLevel.END ) last_seen = db.Column(db.DateTime()) - # Backrefs: role: Role + full_name = db.Column(db.String(64)) + about_me = db.Column(db.String(256)) + location = db.Column(db.String(64)) + website = db.Column(db.String(128)) + organization = db.Column(db.String(128)) + is_public = db.Column(db.Boolean, default=False) + profile_privacy_settings = db.Column(db.Integer(), default=0) # Relationships - tesseract_ocr_pipeline_models = db.relationship( - 'TesseractOCRPipelineModel', - backref='user', + avatar = db.relationship( + 'Avatar', + back_populates='user', cascade='all, delete-orphan', - lazy='dynamic' - ) - spacy_nlp_pipeline_models = db.relationship( - 'SpaCyNLPPipelineModel', - backref='user', - cascade='all, delete-orphan', - lazy='dynamic' + uselist=False ) corpora = db.relationship( 'Corpus', - backref='user', + back_populates='user', cascade='all, delete-orphan', lazy='dynamic' ) + followed_corpus_associations = db.relationship( + 'CorpusFollowerAssociation', + back_populates='following_user' + ) + followed_corpora = association_proxy( + 'followed_corpus_associations', + 'followed_corpus', + creator=lambda c: CorpusFollowerAssociation(followed_corpus=c) + ) jobs = db.relationship( 'Job', - backref='user', + back_populates='user', + cascade='all, delete-orphan', + lazy='dynamic' + ) + role = db.relationship( + 'Role', + back_populates='users' + ) + spacy_nlp_pipeline_models = db.relationship( + 'SpaCyNLPPipelineModel', + back_populates='user', + cascade='all, delete-orphan', + lazy='dynamic' + ) + tesseract_ocr_pipeline_models = db.relationship( + 'TesseractOCRPipelineModel', + back_populates='user', cascade='all, delete-orphan', lazy='dynamic' ) tokens = db.relationship( 'Token', - backref='user', + back_populates='user', cascade='all, delete-orphan', lazy='dynamic' ) - + def __init__(self, **kwargs): super().__init__(**kwargs) if self.role is not None: @@ -481,20 +560,49 @@ class User(HashidMixin, UserMixin, db.Model): return False return check_password_hash(self.password_hash, password) - def to_json_serializeable(self, backrefs=False, relationships=False): + #region Profile Privacy settings + def has_profile_privacy_setting(self, setting): + return self.profile_privacy_settings & setting == setting + + def add_profile_privacy_setting(self, setting): + if not self.has_profile_privacy_setting(setting): + self.profile_privacy_settings += setting + + def remove_profile_privacy_setting(self, setting): + if self.has_profile_privacy_setting(setting): + self.profile_privacy_settings -= setting + + def reset_profile_privacy_settings(self): + self.profile_privacy_settings = 0 + #endregion Profile Privacy settings + + def to_json_serializeable(self, backrefs=False, relationships=False, filter_by_privacy_settings=False): json_serializeable = { 'id': self.hashid, 'confirmed': self.confirmed, 'email': self.email, 'last_seen': ( None if self.last_seen is None - else f'{self.last_seen.isoformat()}Z' + else self.last_seen.strftime('%Y-%m-%d %H:%M') ), - 'member_since': f'{self.member_since.isoformat()}Z', + 'member_since': self.member_since.strftime('%Y-%m-%d'), 'username': self.username, + 'full_name': self.full_name, + 'about_me': self.about_me, + 'website': self.website, + 'location': self.location, + 'organization': self.organization, 'job_status_mail_notification_level': \ - self.setting_job_status_mail_notification_level.name + self.setting_job_status_mail_notification_level.name, + 'is_public': self.is_public, + 'show_email': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL), + 'show_last_seen': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN), + 'show_member_since': self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE) } + json_serializeable['avatar'] = ( + None if self.avatar is None + else self.avatar.to_json_serializeable(relationships=True) + ) if backrefs: json_serializeable['role'] = \ self.role.to_json_serializeable(backrefs=True) @@ -515,8 +623,17 @@ class User(HashidMixin, UserMixin, db.Model): x.hashid: x.to_json_serializeable(relationships=True) for x in self.spacy_nlp_pipeline_models } + + if filter_by_privacy_settings: + if not self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL): + json_serializeable.pop('email') + if not self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN): + json_serializeable.pop('last_seen') + if not self.has_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE): + json_serializeable.pop('member_since') return json_serializeable + class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model): __tablename__ = 'tesseract_ocr_pipeline_models' # Primary key @@ -532,8 +649,9 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model): publisher_url = db.Column(db.String(512)) publishing_url = db.Column(db.String(512)) publishing_year = db.Column(db.Integer) - shared = db.Column(db.Boolean, default=False) - # Backrefs: user: User + is_public = db.Column(db.Boolean, default=False) + # Relationships + user = db.relationship('User', back_populates='tesseract_ocr_pipeline_models') @property def path(self): @@ -576,7 +694,7 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model): model.publisher_url = m['publisher_url'] model.publishing_url = m['publishing_url'] model.publishing_year = m['publishing_year'] - model.shared = True + model.is_public = True model.title = m['title'] model.version = m['version'] continue @@ -587,7 +705,7 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model): publisher_url=m['publisher_url'], publishing_url=m['publishing_url'], publishing_year=m['publishing_year'], - shared=True, + is_public=True, title=m['title'], user=nopaque_user, version=m['version'] @@ -629,7 +747,7 @@ class TesseractOCRPipelineModel(FileMixin, HashidMixin, db.Model): 'publisher_url': self.publisher_url, 'publishing_url': self.publishing_url, 'publishing_year': self.publishing_year, - 'shared': self.shared, + 'is_public': self.is_public, 'title': self.title, 'version': self.version, **self.file_mixin_to_json_serializeable() @@ -656,8 +774,9 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model): publishing_url = db.Column(db.String(512)) publishing_year = db.Column(db.Integer) pipeline_name = db.Column(db.String(64)) - shared = db.Column(db.Boolean, default=False) - # Backrefs: user: User + is_public = db.Column(db.Boolean, default=False) + # Relationships + user = db.relationship('User', back_populates='spacy_nlp_pipeline_models') @property def path(self): @@ -700,7 +819,7 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model): model.publisher_url = m['publisher_url'] model.publishing_url = m['publishing_url'] model.publishing_year = m['publishing_year'] - model.shared = True + model.is_public = True model.title = m['title'] model.version = m['version'] model.pipeline_name = m['pipeline_name'] @@ -712,7 +831,7 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model): publisher_url=m['publisher_url'], publishing_url=m['publishing_url'], publishing_year=m['publishing_year'], - shared=True, + is_public=True, title=m['title'], user=nopaque_user, version=m['version'], @@ -756,7 +875,7 @@ class SpaCyNLPPipelineModel(FileMixin, HashidMixin, db.Model): 'publishing_url': self.publishing_url, 'publishing_year': self.publishing_year, 'pipeline_name': self.pipeline_name, - 'shared': self.shared, + 'is_public': self.is_public, 'title': self.title, 'version': self.version, **self.file_mixin_to_json_serializeable() @@ -772,7 +891,11 @@ class JobInput(FileMixin, HashidMixin, db.Model): id = db.Column(db.Integer, primary_key=True) # Foreign keys job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) - # Backrefs: job: Job + # Relationships + job = db.relationship( + 'Job', + back_populates='inputs' + ) def __repr__(self): return f'' @@ -807,7 +930,7 @@ class JobInput(FileMixin, HashidMixin, db.Model): @property def user_id(self): - return self.job.user_id + return self.job.user.id def to_json_serializeable(self, backrefs=False, relationships=False): json_serializeable = { @@ -828,7 +951,11 @@ class JobResult(FileMixin, HashidMixin, db.Model): job_id = db.Column(db.Integer, db.ForeignKey('jobs.id')) # Fields description = db.Column(db.String(255)) - # Backrefs: job: Job + # Relationships + job = db.relationship( + 'Job', + back_populates='results' + ) def __repr__(self): return f'' @@ -863,7 +990,7 @@ class JobResult(FileMixin, HashidMixin, db.Model): @property def user_id(self): - return self.job.user_id + return self.job.user.id def to_json_serializeable(self, backrefs=False, relationships=False): json_serializeable = { @@ -902,20 +1029,23 @@ class Job(HashidMixin, db.Model): default=JobStatus.INITIALIZING ) title = db.Column(db.String(32)) - # Backrefs: user: User # Relationships inputs = db.relationship( 'JobInput', - backref='job', + back_populates='job', cascade='all, delete-orphan', lazy='dynamic' ) results = db.relationship( 'JobResult', - backref='job', + back_populates='job', cascade='all, delete-orphan', lazy='dynamic' ) + user = db.relationship( + 'User', + back_populates='jobs' + ) def __repr__(self): return f'' @@ -1024,6 +1154,7 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): corpus_id = db.Column(db.Integer, db.ForeignKey('corpora.id')) # Fields author = db.Column(db.String(255)) + description = db.Column(db.String(255)) publishing_year = db.Column(db.Integer) title = db.Column(db.String(255)) address = db.Column(db.String(255)) @@ -1035,7 +1166,11 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): pages = db.Column(db.String(255)) publisher = db.Column(db.String(255)) school = db.Column(db.String(255)) - # Backrefs: corpus: Corpus + # Relationships + corpus = db.relationship( + 'Corpus', + back_populates='files' + ) @property def download_url(self): @@ -1103,6 +1238,7 @@ class CorpusFile(FileMixin, HashidMixin, db.Model): self.corpus.to_json_serializeable(backrefs=True) return json_serializeable + class Corpus(HashidMixin, db.Model): ''' Class to define a corpus. @@ -1123,14 +1259,23 @@ class Corpus(HashidMixin, db.Model): num_analysis_sessions = db.Column(db.Integer, default=0) num_tokens = db.Column(db.Integer, default=0) is_public = db.Column(db.Boolean, default=False) - # Backrefs: user: User # Relationships files = db.relationship( 'CorpusFile', - backref='corpus', + back_populates='corpus', lazy='dynamic', cascade='all, delete-orphan' ) + following_user_associations = db.relationship( + 'CorpusFollowerAssociation', + back_populates='followed_corpus' + ) + following_users = association_proxy( + 'following_user_associations', + 'following_user', + creator=lambda u: CorpusFollowerAssociation(following_user=u) + ) + user = db.relationship('User', back_populates='corpora') # "static" attributes max_num_tokens = 2_147_483_647 @@ -1246,6 +1391,8 @@ class Corpus(HashidMixin, db.Model): @db.event.listens_for(Job, 'after_delete') @db.event.listens_for(JobInput, 'after_delete') @db.event.listens_for(JobResult, 'after_delete') +@db.event.listens_for(SpaCyNLPPipelineModel, 'after_delete') +@db.event.listens_for(TesseractOCRPipelineModel, 'after_delete') def ressource_after_delete(mapper, connection, ressource): jsonpatch = [{'op': 'remove', 'path': ressource.jsonpatch_path}] room = f'users.{ressource.user_hashid}' @@ -1259,6 +1406,8 @@ def ressource_after_delete(mapper, connection, ressource): @db.event.listens_for(Job, 'after_insert') @db.event.listens_for(JobInput, 'after_insert') @db.event.listens_for(JobResult, 'after_insert') +@db.event.listens_for(SpaCyNLPPipelineModel, 'after_insert') +@db.event.listens_for(TesseractOCRPipelineModel, 'after_insert') def ressource_after_insert_handler(mapper, connection, ressource): value = ressource.to_json_serializeable() for attr in mapper.relationships: @@ -1275,6 +1424,8 @@ def ressource_after_insert_handler(mapper, connection, ressource): @db.event.listens_for(Job, 'after_update') @db.event.listens_for(JobInput, 'after_update') @db.event.listens_for(JobResult, 'after_update') +@db.event.listens_for(SpaCyNLPPipelineModel, 'after_update') +@db.event.listens_for(TesseractOCRPipelineModel, 'after_update') def ressource_after_update_handler(mapper, connection, ressource): jsonpatch = [] for attr in db.inspect(ressource).attrs: diff --git a/app/services/forms.py b/app/services/forms.py index 562696eb..1ad544dc 100644 --- a/app/services/forms.py +++ b/app/services/forms.py @@ -73,11 +73,11 @@ class CreateTesseractOCRPipelineJobForm(CreateJobBaseForm): if 'methods' in service_info: if 'binarization' in service_info['methods']: del self.binarization.render_kw['disabled'] - if 'ocropus_nlbin_threshold' in service_info['methods']: - del self.ocropus_nlbin_threshold.render_kw['disabled'] + if 'ocropus_nlbin_threshold' in service_info['methods']: + del self.ocropus_nlbin_threshold.render_kw['disabled'] models = [ x for x in TesseractOCRPipelineModel.query.order_by(TesseractOCRPipelineModel.title).all() - if version in x.compatible_service_versions and (x.shared == True or x.user == current_user) + if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user) ] self.model.choices = [('', 'Choose your option')] self.model.choices += [(x.hashid, f'{x.title} [{x.version}]') for x in models] @@ -157,7 +157,7 @@ class CreateSpacyNLPPipelineJobForm(CreateJobBaseForm): del self.encoding_detection.render_kw['disabled'] models = [ x for x in SpaCyNLPPipelineModel.query.order_by(SpaCyNLPPipelineModel.title).all() - if version in x.compatible_service_versions and (x.shared == True or x.user == current_user) + if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user) ] self.model.choices = [('', 'Choose your option')] self.model.choices += [(x.hashid, f'{x.title} [{x.version}]') for x in models] diff --git a/app/services/routes.py b/app/services/routes.py index 7748240c..0fb4b168 100644 --- a/app/services/routes.py +++ b/app/services/routes.py @@ -98,7 +98,7 @@ def tesseract_ocr_pipeline(): return {}, 201, {'Location': job.url} tesseract_ocr_pipeline_models = [ x for x in TesseractOCRPipelineModel.query.all() - if version in x.compatible_service_versions and (x.shared == True or x.user == current_user) + if version in x.compatible_service_versions and (x.is_public == True or x.user == current_user) ] return render_template( 'services/tesseract_ocr_pipeline.html.j2', @@ -125,6 +125,8 @@ def transkribus_htr_pipeline(): if r.status_code != 200: abort(500) transkribus_htr_pipeline_models = r.json()['trpModelMetadata'] + transkribus_htr_pipeline_models.append({'modelId': 48513, 'name': 'Caroline Minuscle', 'language': 'lat', 'isoLanguages': ['lat']}) + print(transkribus_htr_pipeline_models[len(transkribus_htr_pipeline_models)-1]) form = CreateTranskribusHTRPipelineJobForm( transkribus_htr_pipeline_models=transkribus_htr_pipeline_models, prefix='create-job-form', diff --git a/app/services/services.yml b/app/services/services.yml index 4d00163d..a686f683 100644 --- a/app/services/services.yml +++ b/app/services/services.yml @@ -41,7 +41,7 @@ transkribus-htr-pipeline: spacy-nlp-pipeline: name: 'SpaCy NLP Pipeline' publisher: 'Bielefeld University - CRC 1288 - INF' - latest_version: '0.1.1' + latest_version: '0.1.2' versions: 0.1.0: methods: @@ -53,3 +53,8 @@ spacy-nlp-pipeline: - 'encoding_detection' publishing_year: 2022 url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.1' + 0.1.2: + methods: + - 'encoding_detection' + publishing_year: 2022 + url: 'https://gitlab.ub.uni-bielefeld.de/sfb1288inf/spacy-nlp-pipeline/-/releases/v0.1.2' diff --git a/app/settings/forms.py b/app/settings/forms.py index 4e7eacf2..e90d9dda 100644 --- a/app/settings/forms.py +++ b/app/settings/forms.py @@ -1,22 +1,7 @@ from flask_wtf import FlaskForm -from wtforms import ( - BooleanField, - PasswordField, - SelectField, - StringField, - SubmitField, - ValidationError -) -from wtforms.validators import ( - DataRequired, - InputRequired, - Email, - EqualTo, - Length, - Regexp -) -from app.models import User, UserSettingJobStatusMailNotificationLevel -from app.auth import USERNAME_REGEX +from wtforms import PasswordField, SelectField, SubmitField, ValidationError +from wtforms.validators import DataRequired, EqualTo +from app.models import UserSettingJobStatusMailNotificationLevel class ChangePasswordForm(FlaskForm): @@ -46,53 +31,13 @@ class ChangePasswordForm(FlaskForm): raise ValidationError('Invalid password') -class EditGeneralSettingsForm(FlaskForm): - email = StringField( - 'E-Mail', - validators=[InputRequired(), Length(max=254), Email()] - ) - username = StringField( - 'Username', - validators=[ - InputRequired(), - Length(max=64), - Regexp( - USERNAME_REGEX, - message=( - 'Usernames must have only letters, numbers, dots or ' - 'underscores' - ) - ) - ] - ) - submit = SubmitField() - - def __init__(self, user, *args, **kwargs): - super().__init__(*args, **kwargs) - self.user = user - - def validate_email(self, field): - if (field.data != self.user.email - and User.query.filter_by(email=field.data).first()): - raise ValidationError('Email already registered') - - def validate_username(self, field): - if (field.data != self.user.username - and User.query.filter_by(username=field.data).first()): - raise ValidationError('Username already in use') - - class EditNotificationSettingsForm(FlaskForm): job_status_mail_notification_level = SelectField( 'Job status mail notification level', - choices=[('', 'Choose your option')], + choices=[ + (x.name, x.name.capitalize()) + for x in UserSettingJobStatusMailNotificationLevel + ], validators=[DataRequired()] ) submit = SubmitField() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.job_status_mail_notification_level.choices += [ - (x.name, x.name.capitalize()) - for x in UserSettingJobStatusMailNotificationLevel - ] diff --git a/app/settings/routes.py b/app/settings/routes.py index 26b7fdda..a07869b8 100644 --- a/app/settings/routes.py +++ b/app/settings/routes.py @@ -3,11 +3,7 @@ from flask_login import current_user, login_required from app import db from app.models import UserSettingJobStatusMailNotificationLevel from . import bp -from .forms import ( - ChangePasswordForm, - EditGeneralSettingsForm, - EditNotificationSettingsForm -) +from .forms import ChangePasswordForm, EditNotificationSettingsForm @bp.route('', methods=['GET', 'POST']) @@ -17,42 +13,27 @@ def settings(): current_user, prefix='change-password-form' ) - edit_general_settings_form = EditGeneralSettingsForm( - current_user, - data=current_user.to_json_serializeable(), - prefix='edit-general-settings-form' - ) edit_notification_settings_form = EditNotificationSettingsForm( data=current_user.to_json_serializeable(), prefix='edit-notification-settings-form' ) - + # region handle change_password_form POST if change_password_form.submit.data and change_password_form.validate(): current_user.password = change_password_form.new_password.data db.session.commit() flash('Your changes have been saved') - return redirect(url_for('.index')) - if (edit_general_settings_form.submit.data - and edit_general_settings_form.validate()): - current_user.email = edit_general_settings_form.email.data - current_user.username = edit_general_settings_form.username.data - db.session.commit() - flash('Your changes have been saved') - return redirect(url_for('.settings')) - if (edit_notification_settings_form.submit.data - and edit_notification_settings_form.validate()): - current_user.setting_job_status_mail_notification_level = ( - UserSettingJobStatusMailNotificationLevel[ - edit_notification_settings_form.job_status_mail_notification_level.data # noqa - ] - ) + return redirect(url_for('.settings')) + # endregion handle change_password_form POST + # region handle edit_notification_settings_form POST + if edit_notification_settings_form.submit and edit_notification_settings_form.validate(): + current_user.setting_job_status_mail_notification_level = edit_notification_settings_form.job_status_mail_notification_level.data db.session.commit() flash('Your changes have been saved') return redirect(url_for('.settings')) + # endregion handle edit_notification_settings_form POST return render_template( 'settings/settings.html.j2', change_password_form=change_password_form, - edit_general_settings_form=edit_general_settings_form, edit_notification_settings_form=edit_notification_settings_form, title='Settings' ) diff --git a/app/static/css/colors.scss b/app/static/css/colors.scss index 22feeb41..f8f04228 100644 --- a/app/static/css/colors.scss +++ b/app/static/css/colors.scss @@ -201,15 +201,15 @@ $color: ( @each $ressource-name, $color-palette in map-get($color, "status") { @each $key, $color-code in $color-palette { - .#{$ressource-name}-status-color[data-#{$ressource-name}-status="#{$key}"] { + .#{$ressource-name}-status-color[data-status="#{$key}"] { background-color: $color-code !important; } - .#{$ressource-name}-status-color-border[data-#{$ressource-name}-status="#{$key}"] { + .#{$ressource-name}-status-color-border[data-status="#{$key}"] { border-color: $color-code !important; } - .#{$ressource-name}-status-color-text[data-#{$ressource-name}-status="#{$key}"] { + .#{$ressource-name}-status-color-text[data-status="#{$key}"] { color: $color-code !important; } } diff --git a/app/static/css/style.css b/app/static/css/style.css index e0c288b5..7c56d3d8 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -27,7 +27,7 @@ transform: scale(2); } -.btn-scale-x2 .nopaque-icons.service-icon { +.btn-scale-x2 .nopaque-icons.service-icons { font-size: 2.5rem; } @@ -37,22 +37,23 @@ h1 .nopaque-icons, h2 .nopaque-icons, h3 .nopaque-icons, h4 .nopaque-icons, .tab } -.corpus-status-text {text-transform: lowercase;} -.corpus-status-text[data-corpus-status]:empty:before {content: attr(data-corpus-status);} +.corpus-status-text, .job-status-text {text-transform: lowercase;} +.corpus-status-text[data-status]:empty::before, .job-status-text[data-status]:empty::before {content: attr(data-status);} -.job-status-text {text-transform: lowercase;} -.job-status-text[data-job-status]:empty:before {content: attr(data-job-status);} +.service-scheme[data-service="file-setup-pipeline"] .nopaque-icons.service-icons[data-service="inherit"]:empty::before {content: "E";} +.service-scheme[data-service="tesseract-ocr-pipeline"] .nopaque-icons.service-icons[data-service="inherit"]:empty::before {content: "F";} +.service-scheme[data-service="transkribus-htr-pipeline"] .nopaque-icons.service-icons[data-service="inherit"]:empty::before {content: "F";} +.service-scheme[data-service="spacy-nlp-pipeline"] .nopaque-icons.service-icons[data-service="inherit"]:empty::before {content: "G";} +.service-scheme[data-service="corpus-analysis"] .nopaque-icons.service-icons[data-service="inherit"]:empty::before {content: "H";} -.nopaque-icons.service-icon[data-service="file-setup-pipeline"]:empty:before {content: "E";} -.nopaque-icons.service-icon[data-service="tesseract-ocr-pipeline"]:empty:before {content: "F";} -.nopaque-icons.service-icon[data-service="transkribus-htr-pipeline"]:empty:before {content: "F";} -.nopaque-icons.service-icon[data-service="spacy-nlp-pipeline"]:empty:before {content: "G";} -.nopaque-icons.service-icon[data-service="corpus-analysis"]:empty:before {content: "H";} +.nopaque-icons.service-icons[data-service="file-setup-pipeline"]:empty::before {content: "E";} +.nopaque-icons.service-icons[data-service="tesseract-ocr-pipeline"]:empty::before {content: "F";} +.nopaque-icons.service-icons[data-service="transkribus-htr-pipeline"]:empty::before {content: "F";} +.nopaque-icons.service-icons[data-service="spacy-nlp-pipeline"]:empty::before {content: "G";} +.nopaque-icons.service-icons[data-service="corpus-analysis"]:empty::before {content: "H";} -.clickable { - cursor: pointer !important; - pointer-events: all !important; -} +[draggable="true"] {cursor: move !important;} +.clickable {cursor: pointer !important;} .chip.s-attr .chip.p-attr {background-color: inherit;} diff --git a/app/static/images/parallax_hq/canvas.png b/app/static/images/parallax_hq/canvas.png new file mode 100644 index 00000000..2bdbf0b6 Binary files /dev/null and b/app/static/images/parallax_hq/canvas.png differ diff --git a/app/static/images/user_avatar.png b/app/static/images/user_avatar.png new file mode 100644 index 00000000..09892098 Binary files /dev/null and b/app/static/images/user_avatar.png differ diff --git a/app/static/js/App.js b/app/static/js/App.js index c7a372f8..532bff5c 100644 --- a/app/static/js/App.js +++ b/app/static/js/App.js @@ -8,33 +8,20 @@ class App { this.socket.on('PATCH', (patch) => {this.onPatch(patch);}); } - getUser(userId) { + getUser(userId, backrefs=false, relationships=false) { if (userId in this.data.promises.getUser) { return this.data.promises.getUser[userId]; } this.data.promises.getUser[userId] = new Promise((resolve, reject) => { - fetch(`/users/${userId}?backrefs=true&relationships=true`, {headers: {Accept: 'application/json'}}) - .then( - (response) => { - if (response.status === 403) {this.flash('Forbidden', 'error'); reject(response);} - return response.json(); - }, - (response) => { - this.flash('Something went wrong', 'error'); - reject(response); - } - ) - .then( - (user) => { - this.data.users[userId] = user; - resolve(this.data.users[userId]); - }, - (error) => { - console.error(error, 'error'); - reject(error); - } - ); + this.socket.emit('GET /users/', userId, backrefs, relationships, (response) => { + if (response.status !== 200) { + reject(response); + return; + } + this.data.users[userId] = response.body; + resolve(this.data.users[userId]); + }); }); return this.data.promises.getUser[userId]; @@ -47,11 +34,11 @@ class App { this.data.promises.subscribeUser[userId] = new Promise((resolve, reject) => { this.socket.emit('SUBSCRIBE /users/', userId, (response) => { - if (response.code === 200) { - resolve(response); - } else { + if (response.status !== 200) { reject(response); + return; } + resolve(response); }); }); diff --git a/app/static/js/CorpusAnalysis/CQiClient.js b/app/static/js/CorpusAnalysis/CQiClient.js index a605eb07..4a3706e3 100644 --- a/app/static/js/CorpusAnalysis/CQiClient.js +++ b/app/static/js/CorpusAnalysis/CQiClient.js @@ -401,6 +401,25 @@ class CQiSubcorpus { }); } + partial_export(matchIdList, context=50) { + return new Promise((resolve, reject) => { + const args = { + corpus_name: this.corpus.name, + subcorpus_name: this.name, + match_id_list: matchIdList, + context: context + }; + + this.socket.emit('cqi.corpora.corpus.subcorpora.subcorpus.partial_export', args, response => { + if (response.code === 200) { + resolve(response.payload); + } else { + reject(response); + } + }); + }); + } + fdst_1(cutoff, field, attribute) { return new Promise((resolve, reject) => { const args = { diff --git a/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js b/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js index 1c88d32a..e434c545 100644 --- a/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js +++ b/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js @@ -47,6 +47,8 @@ class CorpusAnalysisConcordance { this.data.corpus.o.query(subcorpusName, query) .then(cQiStatus => { subcorpus.q = query; + subcorpus.selectedItems = new Set(); + if (subcorpusName !== 'Last') {this.data.subcorpora.Last = subcorpus;} return this.data.corpus.o.subcorpora.get(subcorpusName); }) .then(cQiSubcorpus => { @@ -56,7 +58,6 @@ class CorpusAnalysisConcordance { .then( paginatedSubcorpus => { subcorpus.p = paginatedSubcorpus; - if (subcorpus !== 'Last') {this.data.subcorpora.Last = subcorpus;} this.data.subcorpora[subcorpusName] = subcorpus; this.settings.selectedSubcorpus = subcorpusName; this.renderSubcorpusList(); @@ -153,15 +154,140 @@ class CorpusAnalysisConcordance { renderSubcorpusActions() { this.clearSubcorpusActions(); this.elements.subcorpusActions.innerHTML += ` - - file_download + + download - + delete `.trim(); M.Tooltip.init(this.elements.subcorpusActions.querySelectorAll('.tooltipped')); - this.elements.subcorpusActions.querySelector('.delete-subcorpus-trigger').addEventListener('click', event => { + this.elements.subcorpusActions.querySelector('.subcorpus-export-trigger').addEventListener('click', event => { + event.preventDefault(); + let subcorpus = this.data.subcorpora[this.settings.selectedSubcorpus]; + let modalElementId = Utils.generateElementId('export-subcorpus-modal-'); + let exportFormatSelectElementId = Utils.generateElementId('export-format-select-'); + let exportSelectedMatchesOnlyCheckboxElementId = Utils.generateElementId('export-selected-matches-only-checkbox-'); + let exportFileNameInputElementId = Utils.generateElementId('export-file-name-input-'); + let modalElement = Utils.HTMLToElement( + ` + + ` + ); + document.querySelector('#modals').appendChild(modalElement); + let exportFormatSelectElement = modalElement.querySelector(`#${exportFormatSelectElementId}`); + let exportFormatSelect = M.FormSelect.init(exportFormatSelectElement); + let exportSelectedMatchesOnlyCheckboxElement = modalElement.querySelector(`#${exportSelectedMatchesOnlyCheckboxElementId}`); + let exportFileNameInputElement = modalElement.querySelector(`#${exportFileNameInputElementId}`); + let exportButton = modalElement.querySelector('.action-button[data-action="export"]'); + let modal = M.Modal.init( + modalElement, + { + dismissible: false, + onCloseEnd: () => { + exportFormatSelect.destroy(); + modal.destroy(); + modalElement.remove(); + } + } + ); + exportButton.addEventListener('click', event => { + event.preventDefault(); + this.app.disableActionElements(); + this.elements.progress.classList.remove('hide'); + let exportFormat = exportFormatSelectElement.value; + let exportFileName = exportFileNameInputElement.value; + let exportFileNameExtension = exportFormat === 'csv' ? 'csv' : 'json'; + let exportFileNameWithExtension = `${exportFileName}.${exportFileNameExtension}`; + let exportSelectedMatchesOnly = exportSelectedMatchesOnlyCheckboxElement.checked; + let promise; + if (exportSelectedMatchesOnly) { + if (subcorpus.selectedItems.size === 0) { + this.elements.progress.classList.add('hide'); + this.app.enableActionElements(); + app.flash('No matches selected', 'error'); + return; + } + promise = subcorpus.o.partial_export([...subcorpus.selectedItems], 50); + } else { + promise = subcorpus.o.export(50); + } + promise.then( + data => { + let blob; + if (exportFormat === 'csv') { + let csvContent = 'sep=,\r\n'; + csvContent += '"#Match","Text title","Left context","KWIC","Right context"'; + for (let match of data.matches) { + csvContent += '\r\n'; + csvContent += `"${match.num}"`; + csvContent += ','; + let textIds = new Set(); + for (let cpos = match.c[0]; cpos <= match.c[1]; cpos++) { + textIds.add(data.cpos_lookup[cpos].text); + } + csvContent += '"' + [...textIds].map(x => data.text_lookup[x].title.replace('"', '""')).join(', ') + '"'; + csvContent += ','; + if (match.lc !== null) { + let lc_cpos_list = []; + for (let cpos = match.lc[0]; cpos <= match.lc[1]; cpos++) {lc_cpos_list.push(cpos);} + csvContent += '"' + lc_cpos_list.map(x => data.cpos_lookup[x].word.replace('"', '""')).join(' ') + '"'; + } + csvContent += ','; + let c_cpos_list = []; + for (let cpos = match.c[0]; cpos <= match.c[1]; cpos++) {c_cpos_list.push(cpos);} + csvContent += '"' + c_cpos_list.map(x => data.cpos_lookup[x].word.replace('"', '""')).join(' ') + '"'; + csvContent += ','; + let rc_cpos_list = []; + for (let cpos = match.rc[0]; cpos <= match.rc[1]; cpos++) {rc_cpos_list.push(cpos);} + if (match.rc !== null) { + csvContent += '"' + rc_cpos_list.map(x => data.cpos_lookup[x].word.replace('"', '""')).join(' ') + '"'; + } + } + blob = new Blob([csvContent], {type: 'text/csv;charset=utf-8;'}); + } else { + blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json;charset=utf-8;'}); + } + let url = URL.createObjectURL(blob); + let pom = document.createElement('a'); + pom.href = url; + pom.setAttribute('download', exportFileNameWithExtension); + pom.click(); + this.elements.progress.classList.add('hide'); + this.app.enableActionElements(); + }); + }); + modal.open(); + }); + this.elements.subcorpusActions.querySelector('.subcorpus-delete-trigger').addEventListener('click', event => { event.preventDefault(); let subcorpus = this.data.subcorpora[this.settings.selectedSubcorpus]; subcorpus.o.drop().then( @@ -214,6 +340,7 @@ class CorpusAnalysisConcordance { let subcorpus = this.data.subcorpora[this.settings.selectedSubcorpus]; this.clearSubcorpusItems(); for (let item of subcorpus.p.items) { + let itemIsSelected = item.num in subcorpus.selectedItems; this.elements.subcorpusItems.innerHTML += ` ${item.num} @@ -222,8 +349,12 @@ class CorpusAnalysisConcordance { ${this.cposRange2HTML(...item.c)} ${item.rc ? this.cposRange2HTML(...item.rc) : ''} - search - add + + search + + + ${itemIsSelected ? 'check' : 'add'} + `.trim(); @@ -252,6 +383,22 @@ class CorpusAnalysisConcordance { this.app.elements.m.extensionTabs.select('reader-extension-container'); }); } + for (let selectTriggerElement of this.elements.subcorpusItems.querySelectorAll('.select-trigger')) { + selectTriggerElement.addEventListener('click', event => { + event.preventDefault(); + let itemElement = selectTriggerElement.closest('.item'); + let itemId = parseInt(itemElement.dataset.id); + if (subcorpus.selectedItems.has(itemId)) { + subcorpus.selectedItems.delete(itemId); + selectTriggerElement.classList.remove('green'); + selectTriggerElement.querySelector('i').textContent = 'add'; + } else { + subcorpus.selectedItems.add(itemId); + selectTriggerElement.classList.add('green'); + selectTriggerElement.querySelector('i').textContent = 'check'; + } + }); + } } clearSubcorpusPagination() { diff --git a/app/static/js/CorpusAnalysis/QueryBuilder.js b/app/static/js/CorpusAnalysis/QueryBuilder.js index 522054dd..c35e9e90 100644 --- a/app/static/js/CorpusAnalysis/QueryBuilder.js +++ b/app/static/js/CorpusAnalysis/QueryBuilder.js @@ -14,6 +14,7 @@ class ConcordanceQueryBuilder { queryBuilderTutorialModal: document.querySelector('#query-builder-tutorial-modal'), valueValidator: true, + //#region QueryBuilder Elements positionalAttrButton: document.querySelector('#positional-attr-button'), @@ -144,7 +145,7 @@ class ConcordanceQueryBuilder { this.elements.generalOptionsQueryBuilderTutorialInfoIcon.addEventListener('click', () => {this.tutorialIconHandler('#general-options-query-builder');}); this.elements.positionalAttr.addEventListener('change', () => {this.tokenTypeSelector();}); - this.elements.tokenSubmitButton.addEventListener('click', () => {this.addToken();}); + this.elements.tokenSubmitButton.addEventListener('click', () => {this.addTokenToQuery();}); this.elements.ignoreCase.addEventListener('change', () => {this.inputOptionHandler(this.elements.ignoreCase);}); @@ -175,6 +176,7 @@ class ConcordanceQueryBuilder { closeQueryBuilderModal(closeInstance) { let instance = M.Modal.getInstance(closeInstance); instance.close(); + } showPositionalAttrArea() { @@ -205,25 +207,69 @@ class ConcordanceQueryBuilder { this.elements.structuralAttrArea.classList.remove('hide'); } - buttonfactory(dataType, prettyText, queryText) { + queryChipFactory(dataType, prettyQueryText, queryText) { window.location.href = '#query-container'; - this.elements.counter += 1; - queryText = encodeURI(queryText); - let buttonElement = Utils.elementFromString( + queryText = Utils.escape(queryText); + prettyQueryText = Utils.escape(prettyQueryText); + let queryChipElement = Utils.HTMLToElement( ` -
- ${prettyText} - close -
+ + ${prettyQueryText} + close + ` ); - buttonElement.addEventListener('click', () => {this.deleteAttr(buttonElement);}); + queryChipElement.addEventListener('click', () => {this.deleteAttr(queryChipElement);}); + queryChipElement.addEventListener('dragstart', (event) => { + // selects all nodes without target class + let queryChips = this.elements.yourQuery.querySelectorAll('.query-component'); + + // Adds a target chip in front of all draggable childnodes + setTimeout(() => { + let targetChipElement = Utils.HTMLToElement('Drop here'); + for (let element of queryChips) { + if (element === queryChipElement.nextSibling) {continue;} + let targetChipClone = targetChipElement.cloneNode(true); + if (element === queryChipElement) { + // If the dragged element is not at the very end, a target chip is also inserted at the end + if (queryChips[queryChips.length - 1] !== element) { + queryChips[queryChips.length - 1].insertAdjacentElement('afterend', targetChipClone); + } + } else { + element.insertAdjacentElement('beforebegin', targetChipClone); + } + targetChipClone.addEventListener('dragover', (event) => { + event.preventDefault(); + }); + targetChipClone.addEventListener('dragenter', (event) => { + event.preventDefault(); + event.target.style.borderStyle = 'solid dotted'; + }); + targetChipClone.addEventListener('dragleave', (event) => { + event.preventDefault(); + event.target.style.borderStyle = 'hidden'; + }); + targetChipClone.addEventListener('drop', (event) => { + let dropzone = event.target; + dropzone.parentElement.replaceChild(queryChipElement, dropzone); + this.queryPreviewBuilder(); + }); + } + }, 0); + }); + + queryChipElement.addEventListener('dragend', (event) => { + let targets = document.querySelectorAll('.drop-target'); + for (let target of targets) { + target.remove(); + } + }); // Ensures that metadata is always at the end of the query: if (this.elements.yourQuery.lastChild === null || this.elements.yourQuery.lastChild.dataset.type !== 'text-annotation') { - this.elements.yourQuery.appendChild(buttonElement); + this.elements.yourQuery.appendChild(queryChipElement); } else if (this.elements.yourQuery.lastChild.dataset.type === 'text-annotation') { - this.elements.yourQuery.insertBefore(buttonElement, this.elements.yourQuery.lastChild); + this.elements.yourQuery.insertBefore(queryChipElement, this.elements.yourQuery.lastChild); } this.elements.queryContainer.classList.remove('hide'); this.queryPreviewBuilder(); @@ -234,73 +280,11 @@ class ConcordanceQueryBuilder { } } - //#region Drag&Drop Events - dragStartHandler(event) { - // Creates element with the class 'target' and all necessary drop functions, in which drop content can be released - this.elements.dropButton = event.target; - let targetChip = ` -
- Drop here -
- `.trim(); - // selects all nodes without target class - let childNodes = this.elements.yourQuery.querySelectorAll('div:not(.target)'); - - // Adds a target chip in front of all draggable childnodes - setTimeout(() => { - for (let element of childNodes) { - if (element === this.elements.dropButton) { - // If the dragged element is not at the very end, a target chip is also inserted at the end - if (childNodes[childNodes.length - 1] !== element) { - childNodes[childNodes.length - 1].insertAdjacentHTML('afterend', targetChip); - } - } else if (element === this.elements.dropButton.nextSibling) { - continue; - } else { - element.insertAdjacentHTML('beforebegin', targetChip) - } - } - },0); - } - - dragOverHandler(event) { - event.preventDefault(); - } - - dragEnterHandler(event) { - event.preventDefault(); - event.target.style.borderStyle = 'solid dotted'; - } - - dragLeaveHandler(event) { - event.preventDefault(); - event.target.style.borderStyle = 'hidden'; - } - - dragEndHandler(event) { - let targets = document.querySelectorAll('.target'); - for (let target of targets) { - target.remove(); - } - } - - dropHandler(event) { - let dropzone = event.target; - dropzone.parentElement.replaceChild(this.elements.dropButton, dropzone); - this.queryPreviewBuilder(); - } - //#endregion Drag&Drop Events - queryPreviewBuilder() { this.elements.yourQueryContent = []; for (let element of this.elements.yourQuery.childNodes) { let queryElement = decodeURI(element.dataset.query); - if (queryElement.includes('<')) { - queryElement = queryElement.replace('<', '<'); - } - if (queryElement.includes('>')) { - queryElement = queryElement.replace('>', '>'); - } + queryElement = Utils.escape(queryElement); if (queryElement !== 'undefined') { this.elements.yourQueryContent.push(queryElement); } @@ -380,7 +364,7 @@ class ConcordanceQueryBuilder { } clearAll() { - // Everything is reset. After 5 seconds for 5 seconds (with 'instance'), a message is displayed indicating that further information can be obtained via the question mark icon + // Everything is reset. let instance = M.Tooltip.getInstance(this.elements.queryBuilderTutorialInfoIcon); this.hideEverything(); @@ -393,16 +377,20 @@ class ConcordanceQueryBuilder { this.elements.entity.innerHTML = 'Entity'; this.elements.sentence.innerHTML = 'Sentence'; + // If the Modal is open after 5 seconds for 5 seconds (with 'instance'), a message is displayed indicating that further information can be obtained via the question mark icon instance.tooltipEl.style.background = '#98ACD2'; instance.tooltipEl.style.borderTop = 'solid 4px #0064A3'; instance.tooltipEl.style.padding = '10px'; instance.tooltipEl.style.color = 'black'; setTimeout(() => { - instance.open(); - setTimeout(() => { - instance.close(); - }, 5000); + let modalInstance = M.Modal.getInstance(this.elements.concordanceQueryBuilder); + if (modalInstance.isOpen) { + instance.open(); + setTimeout(() => { + instance.close(); + }, 5000); + } }, 5000); } @@ -467,19 +455,19 @@ class ConcordanceQueryBuilder { } - tokenButtonfactory(prettyText, tokenText) { + tokenChipFactory(prettyQueryText, tokenText) { tokenText = encodeURI(tokenText); let builderElement; - let buttonElement; + let queryChipElement; builderElement = document.createElement('div'); builderElement.innerHTML = `
- ${prettyText} + ${prettyQueryText} close
`; - buttonElement = builderElement.firstElementChild; - buttonElement.addEventListener('click', () => {this.deleteTokenAttr(buttonElement);}); - this.elements.tokenQuery.appendChild(buttonElement); + queryChipElement = builderElement.firstElementChild; + queryChipElement.addEventListener('click', () => {this.deleteTokenAttr(queryChipElement);}); + this.elements.tokenQuery.appendChild(queryChipElement); } deleteTokenAttr(attr) { @@ -492,12 +480,12 @@ class ConcordanceQueryBuilder { } - addToken() { + addTokenToQuery() { let c; - let tokenQueryContent = ''; //for ButtonFactory(prettyText) + let tokenQueryContent = ''; //for ButtonFactory(prettyQueryText) let tokenQueryText = ''; //for ButtonFactory(queryText) this.elements.cancelBool = false; - let emptyTokenCheck = false; + let tokenIsEmpty = false; if (this.elements.ignoreCase.checked) { c = ' %c'; @@ -510,7 +498,7 @@ class ConcordanceQueryBuilder { tokenQueryContent += ' ' + element.firstChild.data + ' '; tokenQueryText += decodeURI(element.dataset.tokentext); if (element.innerText.indexOf('empty token') !== -1) { - emptyTokenCheck = true; + tokenIsEmpty = true; } } @@ -570,10 +558,11 @@ class ConcordanceQueryBuilder { // cancelBool looks in disableTokenSubmit() whether a value is passed. If the input fields/dropdowns are empty (cancelBool === true), no token is added. if (this.elements.cancelBool === false) { // Square brackets are added only if it is not an empty token (where they are already present). - if (emptyTokenCheck === false) { + if (tokenIsEmpty === false) { tokenQueryText = '[' + tokenQueryText + ']'; } - this.buttonfactory('token', tokenQueryContent, tokenQueryText); + console.log(tokenQueryText); + this.queryChipFactory('token', tokenQueryContent, tokenQueryText); this.hideEverything(); this.elements.positionalAttrArea.classList.add('hide'); this.elements.tokenQuery.innerHTML = ''; @@ -659,7 +648,7 @@ class ConcordanceQueryBuilder { } emptyTokenHandler() { - this.tokenButtonfactory('empty token', '[]'); + this.tokenChipFactory('empty token', '[]'); this.elements.tokenQueryFilled = true; this.hideEverything(); this.elements.incidenceModifiersButton.classList.remove('hide'); @@ -701,27 +690,27 @@ class ConcordanceQueryBuilder { break; case 'english-pos': this.elements.tokenQueryFilled = true; - this.tokenButtonfactory(`pos=${this.elements.englishPos.value}`, `pos="${this.elements.englishPos.value}"`); - this.tokenButtonfactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); + this.tokenChipFactory(`pos=${this.elements.englishPos.value}`, `pos="${this.elements.englishPos.value}"`); + this.tokenChipFactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); this.elements.englishPosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); break; case 'german-pos': this.elements.tokenQueryFilled = true; - this.tokenButtonfactory(`pos=${this.elements.germanPos.value}`, `pos="${this.elements.germanPos.value}"`); - this.tokenButtonfactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); + this.tokenChipFactory(`pos=${this.elements.germanPos.value}`, `pos="${this.elements.germanPos.value}"`); + this.tokenChipFactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); this.elements.germanPosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); break; case 'simple-pos-button': this.elements.tokenQueryFilled = true; - this.tokenButtonfactory(`simple_pos=${this.elements.simplePos.value}`, `simple_pos="${this.elements.simplePos.value}"`); - this.tokenButtonfactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); + this.tokenChipFactory(`simple_pos=${this.elements.simplePos.value}`, `simple_pos="${this.elements.simplePos.value}"`); + this.tokenChipFactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); this.elements.simplePosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); break; case 'empty-token': - this.tokenButtonfactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); + this.tokenChipFactory('{' + this.elements.nInput.value + '}', '{' + this.elements.nInput.value + '}'); break; default: break; @@ -742,27 +731,27 @@ class ConcordanceQueryBuilder { break; case 'english-pos': this.elements.tokenQueryFilled = true; - this.tokenButtonfactory(`pos=${this.elements.englishPos.value}`, `pos="${this.elements.englishPos.value}"`); - this.tokenButtonfactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); + this.tokenChipFactory(`pos=${this.elements.englishPos.value}`, `pos="${this.elements.englishPos.value}"`); + this.tokenChipFactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); this.elements.englishPosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); break; case 'german-pos': this.elements.tokenQueryFilled = true; - this.tokenButtonfactory(`pos=${this.elements.germanPos.value}`, `pos="${this.elements.germanPos.value}"`); - this.tokenButtonfactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); + this.tokenChipFactory(`pos=${this.elements.germanPos.value}`, `pos="${this.elements.germanPos.value}"`); + this.tokenChipFactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); this.elements.germanPosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); break; case 'simple-pos-button': this.elements.tokenQueryFilled = true; - this.tokenButtonfactory(`simple_pos=${this.elements.simplePos.value}`, `simple_pos="${this.elements.simplePos.value}"`); - this.tokenButtonfactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); + this.tokenChipFactory(`simple_pos=${this.elements.simplePos.value}`, `simple_pos="${this.elements.simplePos.value}"`); + this.tokenChipFactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); this.elements.simplePosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); break; case 'empty-token': - this.tokenButtonfactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); + this.tokenChipFactory(`{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`, `{${this.elements.nmInput.value}, ${this.elements.mInput.value}}`); break; default: break; @@ -772,22 +761,22 @@ class ConcordanceQueryBuilder { incidenceModifiersHandler(elem) { // For word and lemma, the incidence modifiers are inserted in the input field. For the others, one or two chips are created which contain the respective value of the token and the incidence modifier. if (this.elements.positionalAttr.value === 'empty-token') { - this.tokenButtonfactory(elem.innerText, elem.dataset.token); + this.tokenChipFactory(elem.innerText, elem.dataset.token); } else if (this.elements.positionalAttr.value === 'english-pos') { - this.tokenButtonfactory(`pos=${this.elements.englishPos.value}`, `pos="${this.elements.englishPos.value}"`); - this.tokenButtonfactory(elem.innerText, elem.dataset.token); + this.tokenChipFactory(`pos=${this.elements.englishPos.value}`, `pos="${this.elements.englishPos.value}"`); + this.tokenChipFactory(elem.innerText, elem.dataset.token); this.elements.englishPosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); this.elements.tokenQueryFilled = true; } else if (this.elements.positionalAttr.value === 'german-pos') { - this.tokenButtonfactory(`pos=${this.elements.germanPos.value}`, `pos="${this.elements.germanPos.value}"`); - this.tokenButtonfactory(elem.innerText, elem.dataset.token); + this.tokenChipFactory(`pos=${this.elements.germanPos.value}`, `pos="${this.elements.germanPos.value}"`); + this.tokenChipFactory(elem.innerText, elem.dataset.token); this.elements.germanPosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); this.elements.tokenQueryFilled = true; } else if (this.elements.positionalAttr.value === 'simple-pos-button') { - this.tokenButtonfactory(`simple_pos=${this.elements.simplePos.value}`, `simple_pos="${this.elements.simplePos.value}"`); - this.tokenButtonfactory(elem.innerText, elem.dataset.token); + this.tokenChipFactory(`simple_pos=${this.elements.simplePos.value}`, `simple_pos="${this.elements.simplePos.value}"`); + this.tokenChipFactory(elem.innerText, elem.dataset.token); this.elements.simplePosBuilder.classList.add('hide'); this.elements.incidenceModifiersButton.classList.add('hide'); this.elements.tokenQueryFilled = true; @@ -856,8 +845,8 @@ class ConcordanceQueryBuilder { break; } - this.tokenButtonfactory(tokenQueryContent, tokenQueryText); - this.tokenButtonfactory(conditionText, conditionQueryContent); + this.tokenChipFactory(tokenQueryContent, tokenQueryText); + this.tokenChipFactory(conditionText, conditionQueryContent); this.wordBuilder(); } @@ -874,10 +863,10 @@ class ConcordanceQueryBuilder { addSentence() { this.hideEverything(); if (this.elements.sentence.text === 'End Sentence') { - this.buttonfactory('end-sentence', 'Sentence End', ''); + this.queryChipFactory('end-sentence', 'Sentence End', ''); this.elements.sentence.innerHTML = 'Sentence'; } else { - this.buttonfactory('start-sentence', 'Sentence Start', ''); + this.queryChipFactory('start-sentence', 'Sentence Start', ''); this.elements.queryContent.push('sentence'); this.elements.sentence.innerHTML = 'End Sentence'; } @@ -891,7 +880,7 @@ class ConcordanceQueryBuilder { } else { queryText = ''; } - this.buttonfactory('end-entity', 'Entity End', queryText); + this.queryChipFactory('end-entity', 'Entity End', queryText); this.elements.entity.innerHTML = 'Entity'; } else { this.hideEverything(); @@ -901,7 +890,7 @@ class ConcordanceQueryBuilder { } englishEntTypeHandler() { - this.buttonfactory('start-entity', 'Entity Type=' + this.elements.englishEntType.value, ''); + this.queryChipFactory('start-entity', 'Entity Type=' + this.elements.englishEntType.value, ''); this.elements.entity.innerHTML = 'End Entity'; this.hideEverything(); this.elements.entityAnyType = false; @@ -913,7 +902,7 @@ class ConcordanceQueryBuilder { } germanEntTypeHandler() { - this.buttonfactory('start-entity', 'Entity Type=' + this.elements.germanEntType.value, ''); + this.queryChipFactory('start-entity', 'Entity Type=' + this.elements.germanEntType.value, ''); this.elements.entity.innerHTML = 'End Entity'; this.hideEverything(); this.elements.entityAnyType = false; @@ -925,7 +914,7 @@ class ConcordanceQueryBuilder { } emptyEntityButton() { - this.buttonfactory('start-empty-entity', 'Entity Start', ''); + this.queryChipFactory('start-empty-entity', 'Entity Start', ''); this.elements.entity.innerHTML = 'End Entity'; this.hideEverything(); this.elements.entityAnyType = true; @@ -955,7 +944,7 @@ class ConcordanceQueryBuilder { }, 3000); } else { let queryText = `:: match.text_${this.elements.textAnnotationOptions.value}="${this.elements.textAnnotationInput.value}"`; - this.buttonfactory('text-annotation', `${this.elements.textAnnotationOptions.value}=${this.elements.textAnnotationInput.value}`, queryText); + this.queryChipFactory('text-annotation', `${this.elements.textAnnotationOptions.value}=${this.elements.textAnnotationInput.value}`, queryText); this.hideEverything(); } } diff --git a/app/static/js/Forms/Form.js b/app/static/js/Forms/Form.js index d93f3e2c..a9604c69 100644 --- a/app/static/js/Forms/Form.js +++ b/app/static/js/Forms/Form.js @@ -32,7 +32,7 @@ class Form { submit(event) { let request = new XMLHttpRequest(); - let modalElement = Utils.elementFromString( + let modalElement = Utils.HTMLToElement( `