Merge branch 'development'

This commit is contained in:
Patrick Jentsch 2023-02-06 15:51:13 +01:00
commit c323c53f37
95 changed files with 3639 additions and 2274 deletions

View File

@ -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/ #

7
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"recommendations": [
"samuelcolvin.jinjahtml",
"ms-azuretools.vscode-docker",
"ms-python.python"
]
}

1
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -1,4 +1,4 @@
FROM python:3.9.15-slim-bullseye
FROM python:3.8.10-slim-buster
LABEL authors="Patrick Jentsch <p.jentsch@uni-bielefeld.de>"

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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!')

View File

@ -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

View File

@ -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}

View File

@ -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}

View File

@ -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):

View File

@ -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('/<hashid:corpus_id>/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('/<hashid:corpus_id>', 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('/<hashid:corpus_id>/files/<hashid:corpus_file_id>',
methods=['GET', 'POST'])
@bp.route('/<hashid:corpus_id>/files/<hashid:corpus_file_id>', 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('/<hashid:corpus_id>/files/<hashid:corpus_file_id>/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(

View File

@ -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')

View File

@ -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'<Role {self.name}>'
@ -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'<CorpusFollowerAssociation {self.following_user.__repr__()} ~ {self.followed_corpus.__repr__()}>'
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'<JobInput {self.filename}>'
@ -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'<JobResult {self.filename}>'
@ -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'<Job {self.title}>'
@ -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:

View File

@ -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]

View File

@ -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',

View File

@ -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'

View File

@ -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
]

View File

@ -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'
)

View File

@ -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;
}
}

View File

@ -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;}

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -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/<user_id>', 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/<user_id>', userId, (response) => {
if (response.code === 200) {
resolve(response);
} else {
if (response.status !== 200) {
reject(response);
return;
}
resolve(response);
});
});

View File

@ -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 = {

View File

@ -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 += `
<a class="btn-floating btn-small tooltipped waves-effect waves-light corpus-analysis-action download-subcorpus-trigger" data-tooltip="Download subcorpus">
<i class="material-icons">file_download</i>
<a class="btn-floating btn-small tooltipped waves-effect waves-light corpus-analysis-action subcorpus-export-trigger" data-tooltip="Export subcorpus">
<i class="material-icons">download</i>
</a>
<a class="btn-floating btn-small red tooltipped waves-effect waves-light corpus-analysis-action delete-subcorpus-trigger" data-tooltip="Delete subcorpus">
<a class="btn-floating btn-small red tooltipped waves-effect waves-light corpus-analysis-action subcorpus-delete-trigger" data-tooltip="Delete subcorpus">
<i class="material-icons">delete</i>
</a>
`.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(
`
<div class="modal" id="${modalElementId}">
<div class="modal-content">
<h4>Export subcorpus "${subcorpus.o.name}"</h4>
<br>
<div class="row">
<div class="input-field col s3">
<select id="${exportFormatSelectElementId}">
<option value="csv" selected>CSV</option>
<option value="json">JSON</option>
</select>
<label>Export format</label>
</div>
<div class="input-field col s9">
<input id="${exportFileNameInputElementId}" type="text" class="validate" value="export">
<label class="active" for="${exportFileNameInputElementId}">Export filename without filename extension (.csv/.json/...)</label>
</div>
<p class="col s12">
<label>
<input id="${exportSelectedMatchesOnlyCheckboxElementId}" type="checkbox" ${subcorpus.selectedItems.size === 0 ? '' : 'checked'}>
<span>Export selected matches only</span>
</label>
</p>
</div>
</div>
<div class="modal-footer">
<a class="btn-flat modal-close waves-effect waves-light">Cancel</a>
<a class="action-button btn modal-close waves-effect waves-light" data-action="export">Export</a>
</div>
</div>
`
);
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 += `
<tr class="item" data-id="${item.num}">
<td class="num">${item.num}</td>
@ -222,8 +349,12 @@ class CorpusAnalysisConcordance {
<td class="kwic">${this.cposRange2HTML(...item.c)}</td>
<td class="right-context">${item.rc ? this.cposRange2HTML(...item.rc) : ''}</td>
<td class="actions right-align">
<a class="btn-floating btn-small waves-effect waves-light corpus-analysis-action goto-reader-trigger"><i class="material-icons prefix">search</i></a>
<a class="btn-floating btn-small waves-effect waves-light corpus-analysis-action export-trigger"><i class="material-icons prefix">add</i></a>
<a class="btn-floating btn-small waves-effect waves-light corpus-analysis-action goto-reader-trigger">
<i class="material-icons prefix">search</i>
</a>
<a class="btn-floating btn-small waves-effect waves-light corpus-analysis-action select-trigger ${itemIsSelected ? 'green' : ''}">
<i class="material-icons prefix">${itemIsSelected ? 'check' : 'add'}</i>
</a>
</td>
</tr>
`.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() {

View File

@ -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(
`
<div class='chip' data-type='${dataType}' data-query='${queryText}' draggable='true' style='cursor:pointer;' ondragstart='concordanceQueryBuilder.dragStartHandler(event)' ondragend='concordanceQueryBuilder.dragEndHandler(event)'>
${prettyText}
<i class='material-icons close'>close</i>
</div>
<span class="chip query-component" data-type="${dataType}" data-query="${queryText}" draggable="true">
${prettyQueryText}
<i class="material-icons close">close</i>
</span>
`
);
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('<span class="chip drop-target">Drop here</span>');
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 = `
<div class='chip target' ondragover='concordanceQueryBuilder.dragOverHandler(event)' ondragenter='concordanceQueryBuilder.dragEnterHandler(event)' ondragleave='concordanceQueryBuilder.dragLeaveHandler(event)' ondrop='concordanceQueryBuilder.dropHandler(event)'>
Drop here
</div>
`.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('<', '&#60;');
}
if (queryElement.includes('>')) {
queryElement = queryElement.replace('>', '&#62;');
}
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 = `
<div class='chip col s2 l2' style='margin-top:20px;' data-tokentext='${tokenText}'>
${prettyText}
${prettyQueryText}
<i class='material-icons close'>close</i>
</div>`;
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', '</s>');
this.queryChipFactory('end-sentence', 'Sentence End', '</s>');
this.elements.sentence.innerHTML = 'Sentence';
} else {
this.buttonfactory('start-sentence', 'Sentence Start', '<s>');
this.queryChipFactory('start-sentence', 'Sentence Start', '<s>');
this.elements.queryContent.push('sentence');
this.elements.sentence.innerHTML = 'End Sentence';
}
@ -891,7 +880,7 @@ class ConcordanceQueryBuilder {
} else {
queryText = '</ent>';
}
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, '<ent_type="' + this.elements.englishEntType.value + '">');
this.queryChipFactory('start-entity', 'Entity Type=' + this.elements.englishEntType.value, '<ent_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, '<ent_type="' + this.elements.germanEntType.value + '">');
this.queryChipFactory('start-entity', 'Entity Type=' + this.elements.germanEntType.value, '<ent_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', '<ent>');
this.queryChipFactory('start-empty-entity', 'Entity Start', '<ent>');
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();
}
}

View File

@ -32,7 +32,7 @@ class Form {
submit(event) {
let request = new XMLHttpRequest();
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
@ -71,7 +71,7 @@ class Form {
for (let selectElement of this.formElement.querySelectorAll('select')) {
if (selectElement.value === '') {
let inputFieldElement = selectElement.closest('.input-field');
let errorHelperTextElement = Utils.elementFromString(
let errorHelperTextElement = Utils.HTMLToElement(
'<span class="helper-text error-color-text" data-helper-text-type="error">Please select an option.</span>'
);
inputFieldElement.appendChild(errorHelperTextElement);
@ -98,7 +98,7 @@ class Form {
.querySelector(`input[name$="${inputName}"], select[name$="${inputName}"]`)
.closest('.input-field');
for (let inputError of inputErrors) {
let errorHelperTextElement = Utils.elementFromString(
let errorHelperTextElement = Utils.HTMLToElement(
`<span class="helper-text error-color-text" data-helper-type="error">${inputError}</span>`
);
inputFieldElement.appendChild(errorHelperTextElement);

View File

@ -0,0 +1,112 @@
class AdminUserList extends ResourceList {
static autoInit() {
for (let adminUserListElement of document.querySelectorAll('.admin-user-list:not(.no-autoinit)')) {
new AdminUserList(adminUserListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
}
get item() {
return `
<tr class="list-item clickable hoverable">
<td><span class="id-1"></span></td>
<td><span class="username"></span></td>
<td><span class="email"></span></td>
<td><span class="last-seen"></span></td>
<td><span class="role"></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="edit"><i class="material-icons">edit</i></a>
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['member-since']},
'email',
'id-1',
'last-seen',
'role',
'username'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('user-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search user</label>
</div>
<table>
<thead>
<tr>
<th>Id</th>
<th>Username</th>
<th>Email</th>
<th>Last seen</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(user) {
return {
'id': user.id,
'id-1': user.id,
'username': user.username,
'email': user.email,
'last-seen': new Date(user.last_seen).toLocaleString('en-US'),
'member-since': user.member_since,
'role': user.role.name
};
}
sort() {
this.listjs.sort('member-since', {order: 'desc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'delete': {
console.log('delete', itemId);
Utils.deleteUserRequest(itemId);
if (itemId === currentUserId) {window.location.href = '/';}
break;
}
case 'edit': {
window.location.href = `/admin/users/${itemId}/edit`;
break;
}
case 'view': {
window.location.href = `/admin/users/${itemId}`;
break;
}
default: {
break;
}
}
}
}

View File

@ -0,0 +1,151 @@
class CorpusFileList extends ResourceList {
static autoInit() {
for (let corpusFileListElement of document.querySelectorAll('.corpus-file-list:not(.no-autoinit)')) {
new CorpusFileList(corpusFileListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
this.corpusId = listContainerElement.dataset.corpusId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.corpora[this.corpusId].files));
this.isInitialized = true;
});
}
get item() {
return `
<tr class="list-item clickable hoverable">
<td><span class="filename"></span></td>
<td><span class="author"></span></td>
<td><span class="title"></span></td>
<td><span class="publishing-year"></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
'author',
'filename',
'publishing-year',
'title'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('corpus-file-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search corpus file</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Author</th>
<th>Title</th>
<th>Publishing year</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(corpusFile) {
return {
'id': corpusFile.id,
'author': corpusFile.author,
'creation-date': corpusFile.creation_date,
'filename': corpusFile.filename,
'publishing-year': corpusFile.publishing_year,
'title': corpusFile.title
};
}
sort() {
this.listjs.sort('creation-date', {order: 'desc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'delete': {
Utils.deleteCorpusFileRequest(this.userId, this.corpusId, itemId);
break;
}
case 'download': {
window.location.href = `/corpora/${this.corpusId}/files/${itemId}/download`;
break;
}
case 'view': {
window.location.href = `/corpora/${this.corpusId}/files/${itemId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, corpusFileId] = operation.path.match(re);
this.remove(corpusFileId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`);
if (re.test(operation.path)) {
let [match, corpusFileId, valueName] = operation.path.match(re);
this.replace(corpusFileId, valueName.replace('_', '-'), operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -0,0 +1,142 @@
class CorpusList extends ResourceList {
static autoInit() {
for (let corpusListElement of document.querySelectorAll('.corpus-list:not(.no-autoinit)')) {
new CorpusList(corpusListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.corpora));
this.isInitialized = true;
});
}
// #region Mandatory getters and methods to implement
get item() {
return `
<tr class="list-item clickable hoverable">
<td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light" data-list-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
{name: 'status', attr: 'data-status'},
'description',
'title'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('corpus-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search corpus</label>
</div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(corpus) {
return {
'id': corpus.id,
'creation-date': corpus.creation_date,
'description': corpus.description,
'status': corpus.status,
'title': corpus.title
};
}
sort() {
this.listjs.sort('creation-date', {order: 'desc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'delete-request': {
Utils.deleteCorpusRequest(this.userId, itemId);
break;
}
case 'view': {
window.location.href = `/corpora/${itemId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, corpusId] = operation.path.match(re);
this.remove(corpusId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`);
if (re.test(operation.path)) {
let [match, corpusId, valueName] = operation.path.match(re);
this.replace(corpusId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -0,0 +1,96 @@
class JobInputList extends ResourceList {
static autoInit() {
for (let jobInputListElement of document.querySelectorAll('.job-input-list:not(.no-autoinit)')) {
new JobInputList(jobInputListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
this.jobId = listContainerElement.dataset.jobId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.jobs[this.jobId].inputs));
this.isInitialized = true;
});
}
get item() {
return `
<tr class="list-item clickable hoverable">
<td><span class="filename"></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="download"><i class="material-icons">file_download</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
'filename'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('job-input-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search job input</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(jobInput) {
return {
'id': jobInput.id,
'creation-date': jobInput.creation_date,
'filename': jobInput.filename
};
}
sort() {
this.listjs.sort('filename', {order: 'asc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'download' : listActionElement.dataset.listAction;
switch (listAction) {
case 'download': {
window.location.href = `/jobs/${this.jobId}/inputs/${itemId}/download`;
break;
}
default: {
break;
}
}
}
}

View File

@ -0,0 +1,143 @@
class JobList extends ResourceList {
static autoInit() {
for (let jobListElement of document.querySelectorAll('.job-list:not(.no-autoinit)')) {
new JobList(jobListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.jobs));
this.isInitialized = true;
});
}
get item() {
return `
<tr class="list-item clickable hoverable service-scheme">
<td><a class="btn-floating"><i class="nopaque-icons service-icons" data-service="inherit"></i></a></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="badge new job-status-color job-status-text status" data-badge-caption=""></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating darken waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
{data: ['service']},
{name: 'status', attr: 'data-status'},
'description',
'title'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('job-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search job</label>
</div>
<table>
<thead>
<tr>
<th>Service</th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(job) {
return {
'id': job.id,
'creation-date': job.creation_date,
'description': job.description,
'service': job.service,
'status': job.status,
'title': job.title
};
}
sort() {
this.listjs.sort('creation-date', {order: 'desc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'delete-request': {
Utils.deleteJobRequest(this.userId, itemId);
break;
}
case 'view': {
window.location.href = `/jobs/${itemId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, jobId] = operation.path.match(re);
this.remove(jobId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`);
if (re.test(operation.path)) {
let [match, jobId, valueName] = operation.path.match(re);
this.replace(jobId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -0,0 +1,117 @@
class JobResultList extends ResourceList {
static autoInit() {
for (let jobResultListElement of document.querySelectorAll('.job-result-list:not(.no-autoinit)')) {
new JobResultList(jobResultListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
this.jobId = listContainerElement.dataset.jobId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.jobs[this.jobId].results));
this.isInitialized = true;
});
}
get item() {
return `
<tr class="list-item clickable hoverable">
<td><span class="description"></span></td>
<td><span class="filename"></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="download"><i class="material-icons">file_download</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
'description',
'filename'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('job-result-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search job result</label>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(jobResult) {
return {
'id': jobResult.id,
'creation-date': jobResult.creation_date,
'description': jobResult.description,
'filename': jobResult.filename
};
}
sort() {
this.listjs.sort('filename', {order: 'asc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'download' : listActionElement.dataset.listAction;
switch (listAction) {
case 'download': {
window.location.href = `/jobs/${this.jobId}/results/${itemId}/download`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -0,0 +1,78 @@
class ResourceList {
/* A wrapper class for the list.js list.
* This class is not meant to be used directly, instead it should be used as
* a base class for concrete resource list implementations.
*/
static autoInit() {
CorpusList.autoInit();
CorpusFileList.autoInit();
JobList.autoInit();
JobInputList.autoInit();
JobResultList.autoInit();
SpaCyNLPPipelineModelList.autoInit();
TesseractOCRPipelineModelList.autoInit();
UserList.autoInit();
AdminUserList.autoInit();
}
static defaultOptions = {
page: 5,
pagination: {
innerWindow: 2,
outerWindow: 2
}
};
constructor(listContainerElement, options = {}) {
if ('items' in options) {
throw '"items" is not supported as an option, define it as a getter in the list class';
}
if ('valueNames' in options) {
throw '"valueNames" is not supported as an option, define it as a getter in the list class';
}
let _options = Utils.mergeObjectsDeep(
{item: this.item, valueNames: this.valueNames},
ResourceList.defaultOptions,
options
);
this.listContainerElement = listContainerElement;
this.initListContainerElement();
this.listjs = new List(listContainerElement, _options);
}
add(resources, callback) {
let values = resources.map((resource) => {
return this.mapResourceToValue(resource);
});
this.listjs.add(values, (items) => {
this.sort();
if (typeof callback === 'function') {
callback(items);
}
});
}
remove(id) {
this.listjs.remove('id', id);
}
replace(id, key, value) {
let item = this.listjs.get('id', id)[0];
item.values({[key]: value});
}
// #region Mandatory getters and methods to implement
get item() {throw 'Not implemented';}
get valueNames() {throw 'Not implemented';}
initListContainerElement() {throw 'Not implemented';}
// #endregion
// #region Optional methods to implement
mapResourceToValue(resource) {return resource;}
sort() {return;}
// #endregion
}

View File

@ -0,0 +1,192 @@
class SpaCyNLPPipelineModelList extends ResourceList {
static autoInit() {
for (let spaCyNLPPipelineModelListElement of document.querySelectorAll('.spacy-nlp-pipeline-model-list:not(.no-autoinit)')) {
new SpaCyNLPPipelineModelList(spaCyNLPPipelineModelListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('change', (event) => {this.onChange(event)});
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.spacy_nlp_pipeline_models));
this.isInitialized = true;
});
}
get item() {
return (values) => {
return `
<tr class="list-item clickable hoverable">
<td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td>
<td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url publishing-url-2"></a></td>
<td>
<div class="list-action-trigger switch center-align" data-list-action="share-request">
<span class="share"></span>
<label>
<input class="is-public" ${values['is-public'] ? 'checked' : ''} type="checkbox">
<span class="lever"></span>
public
</label>
</div>
</td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light service-2" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
};
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
{name: 'publisher-url', attr: 'href'},
{name: 'publishing-url', attr: 'href'},
'description',
'publisher',
'publishing-url-2',
'publishing-year',
'title',
'title-2',
'version'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('spacy-nlp-pipeline-model-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search SpaCy NLP Pipeline Model</label>
</div>
<table>
<thead>
<tr>
<th>Title and Description</th>
<th>Publisher</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(spaCyNLPPipelineModel) {
return {
'id': spaCyNLPPipelineModel.id,
'creation-date': spaCyNLPPipelineModel.creation_date,
'description': spaCyNLPPipelineModel.description,
'publisher': spaCyNLPPipelineModel.publisher,
'publisher-url': spaCyNLPPipelineModel.publisher_url,
'publishing-url': spaCyNLPPipelineModel.publishing_url,
'publishing-url-2': spaCyNLPPipelineModel.publishing_url,
'publishing-year': spaCyNLPPipelineModel.publishing_year,
'title': spaCyNLPPipelineModel.title,
'title-2': spaCyNLPPipelineModel.title,
'version': spaCyNLPPipelineModel.version,
'is-public': spaCyNLPPipelineModel.is_public
};
}
sort() {
this.listjs.sort('creation-date', {order: 'desc'});
}
onChange(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
if (listActionElement === null) {return;}
let listAction = listActionElement.dataset.listAction;
switch (listAction) {
case 'share-request': {
Utils.spaCyNLPPipelineModelToggleIsPublicRequest(this.userId, itemId);
break;
}
default: {
break;
}
}
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
// ignore switch clicks, handle them by the onChange method instead
if (listActionElement.classList.contains('switch')) {
event.preventDefault();
this.onChange(event);
}
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'delete-request': {
Utils.deleteSpaCyNLPPipelineModelRequest(this.userId, itemId);
break;
}
case 'view': {
window.location.href = `/contributions/spacy-nlp-pipeline-models/${itemId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/spacy_nlp_pipeline_models/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/spacy_nlp_pipeline_models/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/spacy_nlp_pipeline_models/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, itemId] = operation.path.match(re);
this.remove(itemId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/spacy_nlp_pipeline_models/([A-Za-z0-9]*)/(is_public)$`);
if (re.test(operation.path)) {
let [match, itemId, valueName] = operation.path.match(re);
if (valueName === 'is_public') {
this.listjs.list.querySelector(`.list-item[data-id="${itemId}"] .is-public`).checked = operation.value;
valueName = 'is-public';
}
this.replace(itemId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -0,0 +1,201 @@
class TesseractOCRPipelineModelList extends ResourceList {
static autoInit() {
for (let tesseractOCRPipelineModelListElement of document.querySelectorAll('.tesseract-ocr-pipeline-model-list:not(.no-autoinit)')) {
new TesseractOCRPipelineModelList(tesseractOCRPipelineModelListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('change', (event) => {this.onChange(event)});
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
this.userId = listContainerElement.dataset.userId;
app.subscribeUser(this.userId).then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId).then((user) => {
this.add(Object.values(user.tesseract_ocr_pipeline_models));
for (let uncheckedCheckbox of this.listjs.list.querySelectorAll('input[data-checked="True"]')) {
uncheckedCheckbox.setAttribute('checked', '');
}
if (user.role.name !== ('Administrator' || 'Contributor')) {
for (let switchElement of this.listjs.list.querySelectorAll('.is_public')) {
switchElement.setAttribute('disabled', '');
}
}
this.isInitialized = true;
});
}
get item() {
return (values) => {
return `
<tr class="list-item clickable hoverable">
<td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td>
<td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url"><span class="publishing-url-2"></span></a></td>
<td>
<div class="list-action-trigger switch center-align" data-list-action="share-request">
<span class="share"></span>
<label>
<input ${values['is-public'] ? 'checked' : ''} class="is-public" type="checkbox">
<span class="lever"></span>
public
</label>
</div>
</td>
<td class="right-align">
<a class="list-action-trigger btn-floating red waves-effect waves-light" data-list-action="delete-request"><i class="material-icons">delete</i></a>
<a class="list-action-trigger btn-floating service-color darken waves-effect waves-light service-2" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
};
}
get valueNames() {
return [
{data: ['id']},
{data: ['creation-date']},
{name: 'publisher-url', attr: 'href'},
{name: 'publishing-url', attr: 'href'},
'description',
'publisher',
'publishing-url-2',
'publishing-year',
'title',
'title-2',
'version',
{name: 'is_public', attr: 'data-checked'}
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('tesseract-ocr-pipeline-model-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search Tesseract OCR Pipeline Model</label>
</div>
<table>
<thead>
<tr>
<th>Title and Description</th>
<th>Publisher</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(tesseractOCRPipelineModel) {
return {
'id': tesseractOCRPipelineModel.id,
'creation-date': tesseractOCRPipelineModel.creation_date,
'description': tesseractOCRPipelineModel.description,
'publisher': tesseractOCRPipelineModel.publisher,
'publisher-url': tesseractOCRPipelineModel.publisher_url,
'publishing-url': tesseractOCRPipelineModel.publishing_url,
'publishing-url-2': tesseractOCRPipelineModel.publishing_url,
'publishing-year': tesseractOCRPipelineModel.publishing_year,
'title': tesseractOCRPipelineModel.title,
'title-2': tesseractOCRPipelineModel.title,
'version': tesseractOCRPipelineModel.version,
'is-public': tesseractOCRPipelineModel.is_public
};
}
sort() {
this.listjs.sort('creation-date', {order: 'desc'});
}
onChange(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
if (listActionElement === null) {return;}
let listAction = listActionElement.dataset.listAction;
switch (listAction) {
case 'share-request': {
Utils.tesseractOCRPipelineModelToggleIsPublicRequest(this.userId, itemId);
break;
}
default: {
break;
}
}
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
// ignore switch clicks, handle them by the onChange method instead
if (listActionElement.classList.contains('switch')) {
event.preventDefault();
this.onChange(event);
}
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'delete-request': {
Utils.deleteTesseractOCRPipelineModelRequest(this.userId, itemId);
break;
}
case 'view': {
window.location.href = `/contributions/tesseract-ocr-pipeline-models/${itemId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/tesseract_ocr_pipeline_models/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/tesseract_ocr_pipeline_models/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/tesseract_ocr_pipeline_models/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, itemId] = operation.path.match(re);
this.remove(itemId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/tesseract_ocr_pipeline_models/([A-Za-z0-9]*)/(is_public)$`);
if (re.test(operation.path)) {
let [match, itemId, valueName] = operation.path.match(re);
if (valueName === 'is_public') {
this.listjs.list.querySelector(`.list-item[data-id="${itemId}"] .is-public`).checked = operation.value;
valueName = 'is-public';
}
this.replace(itemId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -0,0 +1,104 @@
class UserList extends ResourceList {
static autoInit() {
for (let publicUserListElement of document.querySelectorAll('.user-list:not(.no-autoinit)')) {
new UserList(publicUserListElement);
}
}
constructor(listContainerElement, options = {}) {
super(listContainerElement, options);
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
}
get item() {
return `
<tr class="list-item clickable hoverable">
<td><img alt="user-image" class="circle responsive-img avatar" style="width:50%"></td>
<td><b><span class="username"></span><b></td>
<td><span class="full-name"></span></td>
<td><span class="location"></span></td>
<td><span class="organization"></span></td>
<td><span class="corpora-online"></span></td>
<td class="right-align">
<a class="list-action-trigger btn-floating waves-effect waves-light" data-list-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim();
}
get valueNames() {
return [
{data: ['id']},
{data: ['member-since']},
{name: 'avatar', attr: 'src'},
'username',
'full-name',
'location',
'organization',
'corpora-online'
];
}
initListContainerElement() {
if (!this.listContainerElement.hasAttribute('id')) {
this.listContainerElement.id = Utils.generateElementId('public-user-list-');
}
let listSearchElementId = Utils.generateElementId(`${this.listContainerElement.id}-search-`);
this.listContainerElement.innerHTML = `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${listSearchElementId}" class="search" type="text"></input>
<label for="${listSearchElementId}">Search public user</label>
</div>
<table>
<thead>
<tr>
<th style="width:15%;"></th>
<th>Username</th>
<th>Full name</th>
<th>Location</th>
<th>Organization</th>
<th>Corpora online</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
}
mapResourceToValue(user) {
return {
'id': user.id,
'member-since': user.member_since,
'avatar': user.avatar ? `/users/${user.id}/avatar` : '/static/images/user_avatar.png',
'username': user.username,
'full-name': user.full_name ? user.full_name : '',
'location': user.location ? user.location : '',
'organization': user.organization ? user.organization : '',
'corpora-online': '0'
};
};
sort() {
this.listjs.sort('member-since', {order: 'desc'});
}
onClick(event) {
let listItemElement = event.target.closest('.list-item[data-id]');
if (listItemElement === null) {return;}
let itemId = listItemElement.dataset.id;
let listActionElement = event.target.closest('.list-action-trigger[data-list-action]');
let listAction = listActionElement === null ? 'view' : listActionElement.dataset.listAction;
switch (listAction) {
case 'view': {
window.location.href = `/users/${itemId}`;
break;
}
default: {
break;
}
}
}
}

View File

@ -89,7 +89,7 @@ class CorpusDisplay extends RessourceDisplay {
}
elements = this.displayElement.querySelectorAll('.corpus-status');
for (let element of elements) {
element.dataset.corpusStatus = status;
element.dataset.status = status;
}
elements = this.displayElement.querySelectorAll('.corpus-status-spinner');
for (let element of elements) {

View File

@ -74,7 +74,7 @@ class JobDisplay extends RessourceDisplay {
setStatus(status) {
let elements = this.displayElement.querySelectorAll('.job-status');
for (let element of elements) {
element.dataset.jobStatus = status;
element.dataset.status = status;
}
elements = this.displayElement.querySelectorAll('.job-status-spinner');
for (let element of elements) {

View File

@ -1,130 +0,0 @@
class CorpusFileList extends RessourceList {
static autoInit() {
for (let corpusFileListElement of document.querySelectorAll('.corpus-file-list:not(.no-autoinit)')) {
new CorpusFileList(corpusFileListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search corpus file</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Author</th>
<th>Title</th>
<th>Publishing year</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><span class="filename"></span></td>
<td><span class="author"></span></td>
<td><span class="title"></span></td>
<td><span class="publishing-year"></span></td>
<td class="right-align">
<a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="download" data-service="corpus-analysis"><i class="material-icons">file_download</i></a>
<a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (corpusFile) => {
return {
'id': corpusFile.id,
'author': corpusFile.author,
'creation-date': corpusFile.creation_date,
'filename': corpusFile.filename,
'publishing-year': corpusFile.publishing_year,
'title': corpusFile.title
};
},
sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
'author',
'filename',
'publishing-year',
'title'
]
};
constructor(listElement, options = {}) {
super(listElement, {...CorpusFileList.options, ...options});
this.corpusId = listElement.dataset.corpusId;
}
init(user) {
this._init(user.corpora[this.corpusId].files);
}
onClick(event) {
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let corpusFileElement = event.target.closest('tr');
let corpusFileId = corpusFileElement.dataset.id;
switch (action) {
case 'delete': {
Utils.deleteCorpusFileRequest(this.userId, this.corpusId, corpusFileId);
break;
}
case 'download': {
window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`;
break;
}
case 'view': {
window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, corpusFileId] = operation.path.match(re);
this.remove(corpusFileId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`);
if (re.test(operation.path)) {
let [match, corpusFileId, valueName] = operation.path.match(re);
this.replace(corpusFileId, valueName.replace('_', '-'), operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -1,120 +0,0 @@
class CorpusList extends RessourceList {
static autoInit() {
for (let corpusListElement of document.querySelectorAll('.corpus-list:not(.no-autoinit)')) {
new CorpusList(corpusListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search corpus</label>
</div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td>
<td class="right-align">
<a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (corpus) => {
return {
'id': corpus.id,
'creation-date': corpus.creation_date,
'description': corpus.description,
'status': corpus.status,
'title': corpus.title
};
},
sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
{name: 'status', attr: 'data-corpus-status'},
'description',
'title'
]
};
constructor(listElement, options = {}) {
super(listElement, {...CorpusList.options, ...options});
}
init(user) {
this._init(user.corpora);
}
onClick(event) {
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let corpusElement = event.target.closest('tr');
let corpusId = corpusElement.dataset.id;
switch (action) {
case 'delete-request': {
Utils.deleteCorpusRequest(this.userId, corpusId);
break;
}
case 'view': {
window.location.href = `/corpora/${corpusId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, corpusId] = operation.path.match(re);
this.remove(corpusId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`);
if (re.test(operation.path)) {
let [match, corpusId, valueName] = operation.path.match(re);
this.replace(corpusId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -1,77 +0,0 @@
class JobInputList extends RessourceList {
static autoInit() {
for (let jobInputListElement of document.querySelectorAll('.job-input-list:not(.no-autoinit)')) {
new JobInputList(jobInputListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search job input</label>
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><span class="filename"></span></td>
<td class="right-align">
<a class="action-button btn-floating waves-effect waves-light" data-action="download"><i class="material-icons">file_download</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (jobInput) => {
return {
'id': jobInput.id,
'creation-date': jobInput.creation_date,
'filename': jobInput.filename
};
},
sortArgs: ['filename', {order: 'asc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
'filename'
]
};
constructor(listElement, options = {}) {
super(listElement, {...JobInputList.options, ...options});
this.jobId = listElement.dataset.jobId;
}
init(user) {
this._init(user.jobs[this.jobId].inputs);
}
onClick(event) {
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action;
let jobInputElement = event.target.closest('tr');
let jobInputId = jobInputElement.dataset.id;
switch (action) {
case 'download': {
window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {return;}
}

View File

@ -1,126 +0,0 @@
class JobList extends RessourceList {
static autoInit() {
for (let jobListElement of document.querySelectorAll('.job-list:not(.no-autoinit)')) {
new JobList(jobListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search job</label>
</div>
<table>
<thead>
<tr>
<th>Service</th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable service-color lighten">
<td><a class="btn-floating disabled"><i class="service-1 nopaque-icons service-color darken service-icon"></i></a></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="status badge new job-status-color job-status-text" data-badge-caption=""></span></td>
<td class="right-align">
<a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (job) => {
return {
'id': job.id,
'creation-date': job.creation_date,
'description': job.description,
'service': job.service,
'service-1': job.service,
'service-2': job.service,
'status': job.status,
'title': job.title
};
},
sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
{data: ['service']},
{name: 'service-1', attr: 'data-service'},
{name: 'service-2', attr: 'data-service'},
{name: 'status', attr: 'data-job-status'},
'description',
'title'
]
};
constructor(listElement, options = {}) {
super(listElement, {...JobList.options, ...options});
}
init(user) {
this._init(user.jobs);
}
onClick(event) {
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let jobElement = event.target.closest('tr');
let jobId = jobElement.dataset.id;
switch (action) {
case 'delete-request': {
Utils.deleteJobRequest(this.userId, jobId);
break;
}
case 'view': {
window.location.href = `/jobs/${jobId}`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
case 'remove': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {
let [match, jobId] = operation.path.match(re);
this.remove(jobId);
}
break;
}
case 'replace': {
let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`);
if (re.test(operation.path)) {
let [match, jobId, valueName] = operation.path.match(re);
this.replace(jobId, valueName, operation.value);
}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -1,96 +0,0 @@
class JobResultList extends RessourceList {
static autoInit() {
for (let jobResultListElement of document.querySelectorAll('.job-result-list:not(.no-autoinit)')) {
new JobResultList(jobResultListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search job result</label>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th>Filename</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><span class="description"></span></td>
<td><span class="filename"></span></td>
<td class="right-align">
<a class="action-button btn-floating waves-effect waves-light" data-action="download"><i class="material-icons">file_download</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (jobResult) => {
return {
'id': jobResult.id,
'creation-date': jobResult.creation_date,
'description': jobResult.description,
'filename': jobResult.filename
};
},
sortArgs: ['filename', {order: 'asc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
'description',
'filename'
]
};
constructor(listElement, options = {}) {
super(listElement, {...JobResultList.options, ...options});
this.jobId = listElement.dataset.jobId;
}
init(user) {
super._init(user.jobs[this.jobId].results);
}
onClick(event) {
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'download' : actionButtonElement.dataset.action;
let jobResultElement = event.target.closest('tr');
let jobResultId = jobResultElement.dataset.id;
switch (action) {
case 'download': {
window.location.href = `/jobs/${this.jobId}/results/${jobResultId}/download`;
break;
}
default: {
break;
}
}
}
onPatch(patch) {
let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
let filteredPatch = patch.filter(operation => re.test(operation.path));
for (let operation of filteredPatch) {
switch(operation.op) {
case 'add': {
let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`);
if (re.test(operation.path)) {this.add(operation.value);}
break;
}
default: {
break;
}
}
}
}
}

View File

@ -1,132 +0,0 @@
class RessourceList {
/* A wrapper class for the list.js list.
* This class is not meant to be used directly, instead it should be used as
* a base class for concrete ressource list implementations.
*/
static autoInit() {
CorpusList.autoInit();
CorpusFileList.autoInit();
JobList.autoInit();
JobInputList.autoInit();
JobResultList.autoInit();
SpaCyNLPPipelineModelList.autoInit();
TesseractOCRPipelineModelList.autoInit();
UserList.autoInit();
}
static options = {page: 5, pagination: {innerWindow: 4, outerWindow: 1}};
constructor(listElement, options = {}) {
if (!(listElement.hasAttribute('id'))) {
let i;
for (i = 0; true; i++) {
if (document.querySelector(`#ressource-list-${i}`)) {continue;}
listElement.id = `ressource-list-${i}`;
break;
}
}
options = {
...{pagination: {item: `<li><a class="page" href="#${listElement.id}"></a></li>`}},
...options
}
if ('ressourceMapper' in options) {
this.ressourceMapper = options.ressourceMapper;
delete options.ressourceMapper;
}
if ('initialHtmlGenerator' in options) {
this.initialHtmlGenerator = options.initialHtmlGenerator;
listElement.innerHTML = this.initialHtmlGenerator(listElement.id);
delete options.initialHtmlGenerator;
}
if ('sortArgs' in options) {
this.sortArgs = options.sortArgs;
delete options.sortArgs;
}
this.listjs = new List(listElement, {...RessourceList.options, ...options});
this.listjs.list.innerHTML = `
<tr>
<td class="row" colspan="100%">
<div class="col s12">&nbsp;</div>
<div class="col s3 m2 xl1">
<div class="preloader-wrapper active">
<div class="spinner-layer spinner-green-only">
<div class="circle-clipper left">
<div class="circle"></div>
</div>
<div class="gap-patch">
<div class="circle"></div>
</div>
<div class="circle-clipper right">
<div class="circle"></div>
</div>
</div>
</div>
</div>
<div class="col s9 m6 xl5">
<span class="card-title">Waiting for data...</span>
<p>This list is not initialized yet.</p>
</div>
</td>
</tr>
`.trim();
this.userId = this.listjs.listContainer.dataset.userId;
this.listjs.list.addEventListener('click', (event) => {this.onClick(event)});
this.isInitialized = false;
if (this.userId) {
app.subscribeUser(this.userId)
.then((response) => {
app.socket.on('PATCH', (patch) => {
if (this.isInitialized) {this.onPatch(patch);}
});
});
app.getUser(this.userId)
.then((user) => {
this.init(user);
this.isInitialized = true;
});
}
}
_init(ressources) {
this.listjs.clear();
this.add(Object.values(ressources));
this.listjs.list.insertAdjacentHTML(
'afterbegin',
`
<tr class="show-if-only-child">
<td colspan="100%">
<span class="card-title"><i class="left material-icons" style="font-size: inherit;">file_download</i>Nothing here...</span>
<p>No ressource available.</p>
</td>
</tr>
`.trim()
);
}
init(user) {throw 'Not implemented';}
onClick(event) {throw 'Not implemented';}
onPatch(patch) {throw 'Not implemented';}
add(ressources) {
let values = Array.isArray(ressources) ? ressources : [ressources];
if ('ressourceMapper' in this) {
values = values.map((value) => {return this.ressourceMapper(value);});
}
this.listjs.add(values, () => {
if ('sortArgs' in this) {
this.listjs.sort(...this.sortArgs);
}
});
}
remove(id) {
this.listjs.remove('id', id);
}
replace(id, valueName, newValue) {
this.listjs.get('id', id)[0].values({[valueName]: newValue});
}
}

View File

@ -1,146 +0,0 @@
class SpaCyNLPPipelineModelList extends RessourceList {
static autoInit() {
for (let spaCyNLPPipelineModelListElement of document.querySelectorAll('.spacy-nlp-pipeline-model-list:not(.no-autoinit)')) {
new SpaCyNLPPipelineModelList(spaCyNLPPipelineModelListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search SpaCy NLP Pipeline Model</label>
</div>
<table>
<thead>
<tr>
<th>Title and Description</th>
<th>Publisher</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td>
<td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url"><span class="publishing-url-2"></span></a></td>
<td>
<div class="switch action-switch center-align" data-action="share-request">
<span class="share"></span>
<label>
<input type="checkbox" class="shared">
<span class="lever"></span>
shared
</label>
</div>
</td>
<td class="right-align">
<a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (spaCyNLPPipelineModel) => {
return {
'id': spaCyNLPPipelineModel.id,
'creation-date': spaCyNLPPipelineModel.creation_date,
'description': spaCyNLPPipelineModel.description,
'publisher': spaCyNLPPipelineModel.publisher,
'publisher-url': spaCyNLPPipelineModel.publisher_url,
'publishing-url': spaCyNLPPipelineModel.publishing_url,
'publishing-url-2': spaCyNLPPipelineModel.publishing_url,
'publishing-year': spaCyNLPPipelineModel.publishing_year,
'title': spaCyNLPPipelineModel.title,
'title-2': spaCyNLPPipelineModel.title,
'version': spaCyNLPPipelineModel.version,
'shared': spaCyNLPPipelineModel.shared ? 'True' : 'False'
};
},
sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
{name: 'publisher-url', attr: 'href'},
{name: 'publishing-url', attr: 'href'},
'description',
'publisher',
'publishing-url-2',
'publishing-year',
'title',
'title-2',
'version',
{name: 'shared', attr: 'data-checked'}
]
};
constructor(listElement, options = {}) {
super(listElement, {...SpaCyNLPPipelineModelList.options, ...options});
this.listjs.list.addEventListener('change', (event) => {this.onChange(event)});
}
init(user) {
this._init(user.spacy_nlp_pipeline_models);
if (user.role.name !== ('Administrator' || 'Contributor')) {
for (let switchElement of this.listjs.list.querySelectorAll('.shared')) {
switchElement.setAttribute('disabled', '');
}
}
}
_init(ressources) {
super._init(ressources);
for (let uncheckedCheckbox of this.listjs.list.querySelectorAll('input[data-checked="True"]')) {
uncheckedCheckbox.setAttribute('checked', '');
}
}
onClick(event) {
if (event.target.closest('.action-switch')) {
let userRole = app.data.users[this.userId].role.name;
if (userRole !== ('Administrator' || 'Contributor')) {
app.flash('You need the "Contributor" or "Administrator" role to perform this action.', 'error');
}
return;
}
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let spaCyNLPPipelineModelElement = event.target.closest('tr');
let spaCyNLPPipelineModelId = spaCyNLPPipelineModelElement.dataset.id;
switch (action) {
case 'delete-request': {
Utils.deleteSpaCyNLPPipelineModelRequest(this.userId, spaCyNLPPipelineModelId);
break;
}
case 'view': {
window.location.href = `/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}`;
break;
}
default: {
break;
}
}
}
onChange(event) {
let actionSwitchElement = event.target.closest('.action-switch');
let action = actionSwitchElement.dataset.action;
let spaCyNLPPipelineModelElement = event.target.closest('tr');
let spaCyNLPPipelineModelId = spaCyNLPPipelineModelElement.dataset.id;
switch (action) {
case 'share-request': {
let shared = actionSwitchElement.querySelector('input').checked;
Utils.shareSpaCyNLPPipelineModelRequest(this.userId, spaCyNLPPipelineModelId, shared);
break;
}
default: {
break;
}
}
}
}

View File

@ -1,146 +0,0 @@
class TesseractOCRPipelineModelList extends RessourceList {
static autoInit() {
for (let tesseractOCRPipelineModelListElement of document.querySelectorAll('.tesseract-ocr-pipeline-model-list:not(.no-autoinit)')) {
new TesseractOCRPipelineModelList(tesseractOCRPipelineModelListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search Tesseract OCR Pipeline Model</label>
</div>
<table>
<thead>
<tr>
<th>Title and Description</th>
<th>Publisher</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><b><span class="title"></span> <span class="version"></span></b><br><i><span class="description"></span></i></td>
<td><a class="publisher-url"><span class="publisher"></span></a> (<span class="publishing-year"></span>)<br><a class="publishing-url"><span class="publishing-url-2"></span></a></td>
<td>
<div class="switch action-switch center-align" data-action="share-request">
<span class="share"></span>
<label>
<input type="checkbox" class="shared">
<span class="lever"></span>
shared
</label>
</div>
</td>
<td class="right-align">
<a class="action-button btn-floating red waves-effect waves-light" data-action="delete-request"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating service-color darken waves-effect waves-light service-2" data-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (tesseractOCRPipelineModel) => {
return {
'id': tesseractOCRPipelineModel.id,
'creation-date': tesseractOCRPipelineModel.creation_date,
'description': tesseractOCRPipelineModel.description,
'publisher': tesseractOCRPipelineModel.publisher,
'publisher-url': tesseractOCRPipelineModel.publisher_url,
'publishing-url': tesseractOCRPipelineModel.publishing_url,
'publishing-url-2': tesseractOCRPipelineModel.publishing_url,
'publishing-year': tesseractOCRPipelineModel.publishing_year,
'title': tesseractOCRPipelineModel.title,
'title-2': tesseractOCRPipelineModel.title,
'version': tesseractOCRPipelineModel.version,
'shared': tesseractOCRPipelineModel.shared ? 'True' : 'False'
};
},
sortArgs: ['creation-date', {order: 'desc'}],
valueNames: [
{data: ['id']},
{data: ['creation-date']},
{name: 'publisher-url', attr: 'href'},
{name: 'publishing-url', attr: 'href'},
'description',
'publisher',
'publishing-url-2',
'publishing-year',
'title',
'title-2',
'version',
{name: 'shared', attr: 'data-checked'}
]
};
constructor(listElement, options = {}) {
super(listElement, {...TesseractOCRPipelineModelList.options, ...options});
this.listjs.list.addEventListener('change', (event) => {this.onChange(event)});
}
init (user) {
this._init(user.tesseract_ocr_pipeline_models);
if (user.role.name !== ('Administrator' || 'Contributor')) {
for (let switchElement of this.listjs.list.querySelectorAll('.shared')) {
switchElement.setAttribute('disabled', '');
}
}
}
_init(ressources) {
super._init(ressources);
for (let uncheckedCheckbox of this.listjs.list.querySelectorAll('input[data-checked="True"]')) {
uncheckedCheckbox.setAttribute('checked', '');
}
}
onClick(event) {
if (event.target.closest('.action-switch')) {
let userRole = app.data.users[this.userId].role.name;
if (userRole !== ('Administrator' || 'Contributor')) {
app.flash('You need the "Contributor" or "Administrator" role to perform this action.', 'error');
}
return;
}
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let tesseractOCRPipelineModelElement = event.target.closest('tr');
let tesseractOCRPipelineModelId = tesseractOCRPipelineModelElement.dataset.id;
switch (action) {
case 'delete-request': {
Utils.deleteTesseractOCRPipelineModelRequest(this.userId, tesseractOCRPipelineModelId);
break;
}
case 'view': {
window.location.href = `/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}`;
break;
}
default: {
break;
}
}
}
onChange(event) {
let actionSwitchElement = event.target.closest('.action-switch');
let action = actionSwitchElement.dataset.action;
let tesseractOCRPipelineModelElement = event.target.closest('tr');
let tesseractOCRPipelineModelId = tesseractOCRPipelineModelElement.dataset.id;
switch (action) {
case 'share-request': {
let shared = actionSwitchElement.querySelector('input').checked;
Utils.shareTesseractOCRPipelineModelRequest(this.userId, tesseractOCRPipelineModelId, shared);
break;
}
default: {
break;
}
}
}
}

View File

@ -1,101 +0,0 @@
class UserList extends RessourceList {
static autoInit() {
for (let userListElement of document.querySelectorAll('.user-list:not(.no-autoinit)')) {
new UserList(userListElement);
}
}
static options = {
initialHtmlGenerator: (id) => {
return `
<div class="input-field">
<i class="material-icons prefix">search</i>
<input id="${id}-search" class="search" type="search"></input>
<label for="${id}-search">Search user</label>
</div>
<table>
<thead>
<tr>
<th>Id</th>
<th>Username</th>
<th>Email</th>
<th>Last seen</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
`.trim();
},
item: `
<tr class="clickable hoverable">
<td><span class="id-1"></span></td>
<td><span class="username"></span></td>
<td><span class="email"></span></td>
<td><span class="last-seen"></span></td>
<td><span class="role"></span></td>
<td class="right-align">
<a class="action-button btn-floating red waves-effect waves-light" data-action="delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating waves-effect waves-light" data-action="edit"><i class="material-icons">edit</i></a>
<a class="action-button btn-floating waves-effect waves-light" data-action="view"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
ressourceMapper: (user) => {
return {
'id': user.id,
'id-1': user.id,
'username': user.username,
'email': user.email,
'last-seen': new Date(user.last_seen).toLocaleString('en-US'),
'member-since': user.member_since,
'role': user.role.name
};
},
sortArgs: ['member-since', {order: 'desc'}],
valueNames: [
{data: ['id']},
{data: ['member-since']},
'email',
'id-1',
'last-seen',
'role',
'username'
]
};
constructor(listElement, options = {}) {
super(listElement, {...UserList.options, ...options});
}
init(users) {
super._init(Object.values(users));
}
onClick(event) {
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
let userElement = event.target.closest('tr');
let userId = userElement.dataset.id;
switch (action) {
case 'delete': {
Utils.deleteUserRequest(userId);
if (userId === currentUserId) {window.location.href = '/';}
break;
}
case 'edit': {
window.location.href = `/admin/users/${userId}/edit`;
break;
}
case 'view': {
window.location.href = `/admin/users/${userId}`;
break;
}
default: {
break;
}
}
}
}

View File

@ -1,21 +1,90 @@
class Utils {
static elementFromString(string) {
let tmpElement = document.createElement('div');
tmpElement.innerHTML = string.trim();
return tmpElement.firstChild;
static escape(text) {
// https://codereview.stackexchange.com/a/126722
var table = {
'<': 'lt',
'>': 'gt',
'"': 'quot',
'\'': 'apos',
'&': 'amp',
'\r': '#10',
'\n': '#13'
};
return text.toString().replace(/[<>"'\r\n&]/g, (chr) => {
return '&' + table[chr] + ';';
});
};
static HTMLToElement(HTMLString) {
let templateElement = document.createElement('template');
templateElement.innerHTML = HTMLString.trim();
return templateElement.content.firstChild;
}
static generateElementId(prefix='', suffix='') {
for (let i = 0; true; i++) {
if (document.querySelector(`#${prefix}${i}${suffix}`) !== null) {continue;}
return `${prefix}${i}${suffix}`;
}
}
static isObject(object) {
return object !== null && typeof object === 'object' && !Array.isArray(object);
}
static mergeObjectsDeep(...objects) {
let mergedObject = {};
if (objects.length === 0) {
return mergedObject;
}
if (!Utils.isObject(objects[0])) {throw 'Cannot merge non-object';}
if (objects.length === 1) {
return Utils.mergeObjectsDeep(mergedObject, objects[0]);
}
if (!Utils.isObject(objects[1])) {throw 'Cannot merge non-object';}
for (let key in objects[0]) {
if (objects[0].hasOwnProperty(key)) {
if (objects[1].hasOwnProperty(key)) {
if (Utils.isObject(objects[0][key]) && Utils.isObject(objects[1][key])) {
mergedObject[key] = Utils.mergeObjectsDeep(objects[0][key], objects[1][key]);
} else {
mergedObject[key] = objects[1][key];
}
} else {
mergedObject[key] = objects[0][key];
}
}
}
for (let key in objects[1]) {
if (objects[1].hasOwnProperty(key)) {
if (!objects[0].hasOwnProperty(key)) {
mergedObject[key] = objects[1][key];
}
}
}
if (objects.length === 2) {
return mergedObject;
}
return Utils.mergeObjectsDeep(mergedObject, ...objects.slice(2));
}
static buildCorpusRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus = app.data.users[userId].corpora[corpusId];
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
fetch(`/corpora/${corpus.id}/build`, {method: 'POST', headers: {Accept: 'application/json'}})
fetch(`/corpora/${corpusId}/build`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
if (response.status === 409) {app.flash('Conflict', 'error'); reject(response);}
app.flash(`Corpus "${corpus.title}" marked for building`, 'corpus');
app.flash(`Corpus "${corpus?.title}" marked for building`, 'corpus');
resolve(response);
},
(response) => {
@ -28,14 +97,19 @@ class Utils {
static deleteCorpusRequest(userId, corpusId) {
return new Promise((resolve, reject) => {
let corpus = app.data.users[userId].corpora[corpusId];
let corpus;
try {
corpus = app.data.users[userId].corpora[corpusId];
} catch (error) {
corpus = {};
}
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus deletion</h4>
<p>Do you really want to delete the Corpus <b>${corpus.title}</b>? All files will be permanently deleted!</p>
<p>Do you really want to delete the Corpus <b>${corpus?.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -58,13 +132,13 @@ class Utils {
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let corpusTitle = corpus.title;
fetch(`/corpora/${corpus.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
let corpusTitle = corpus?.title;
fetch(`/corpora/${corpusId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Corpus "${corpus.title}" marked for deletion`, 'corpus');
app.flash(`Corpus "${corpusTitle}" marked for deletion`, 'corpus');
resolve(response);
},
(response) => {
@ -79,15 +153,19 @@ class Utils {
static deleteCorpusFileRequest(userId, corpusId, corpusFileId) {
return new Promise((resolve, reject) => {
let corpus = app.data.users[userId].corpora[corpusId];
let corpusFile = corpus.files[corpusFileId];
let corpusFile;
try {
corpusFile = app.data.users[userId].corpora[corpusId].files[corpusFileId];
} catch (error) {
corpusFile = {};
}
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Corpus File deletion</h4>
<p>Do you really want to delete the Corpus File <b>${corpusFile.title}</b>? All files will be permanently deleted!</p>
<p>Do you really want to delete the Corpus File <b>${corpusFile?.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -110,7 +188,7 @@ class Utils {
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let corpusFileTitle = corpusFile.title;
let corpusFileTitle = corpusFile?.title;
fetch(`/corpora/${corpusId}/files/${corpusFileId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
@ -131,13 +209,19 @@ class Utils {
static deleteSpaCyNLPPipelineModelRequest(userId, spaCyNLPPipelineModelId) {
return new Promise((resolve, reject) => {
let spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
let modalElement = Utils.elementFromString(
let spaCyNLPPipelineModel;
try {
spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
} catch (error) {
spaCyNLPPipelineModel = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm SpaCy NLP Pipeline Model deletion</h4>
<p>Do you really want to delete the SpaCy NLP Pipeline Model <b>${spaCyNLPPipelineModel.title}</b>? All files will be permanently deleted!</p>
<p>Do you really want to delete the SpaCy NLP Pipeline Model <b>${spaCyNLPPipelineModel?.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -159,7 +243,7 @@ class Utils {
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let spaCyNLPPipelineModelTitle = spaCyNLPPipelineModel.title;
let spaCyNLPPipelineModelTitle = spaCyNLPPipelineModel?.title;
fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}`, {method: 'DELETE'})
.then(
(response) => {
@ -180,13 +264,19 @@ class Utils {
static deleteTesseractOCRPipelineModelRequest(userId, tesseractOCRPipelineModelId) {
return new Promise((resolve, reject) => {
let tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
let modalElement = Utils.elementFromString(
let tesseractOCRPipelineModel;
try {
tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
} catch (error) {
tesseractOCRPipelineModel = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Tesseract OCR Pipeline Model deletion</h4>
<p>Do you really want to delete the Tesseract OCR Pipeline Model <b>${tesseractOCRPipelineModel.title}</b>? All files will be permanently deleted!</p>
<p>Do you really want to delete the Tesseract OCR Pipeline Model <b>${tesseractOCRPipelineModel?.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -208,7 +298,7 @@ class Utils {
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let tesseractOCRPipelineModelTitle = tesseractOCRPipelineModel.title;
let tesseractOCRPipelineModelTitle = tesseractOCRPipelineModel?.title;
fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}`, {method: 'DELETE'})
.then(
(response) => {
@ -227,16 +317,14 @@ class Utils {
});
}
static deleteJobRequest(userId, jobId) {
static deleteProfileAvatarRequest(userId) {
return new Promise((resolve, reject) => {
let job = app.data.users[userId].jobs[jobId];
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Job deletion</h4>
<p>Do you really want to delete the Job <b>${job.title}</b>? All files will be permanently deleted!</p>
<h4>Confirm Avatar deletion</h4>
<p>Do you really want to delete your Avatar?</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -259,8 +347,63 @@ class Utils {
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let jobTitle = job.title;
fetch(`/jobs/${job.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
fetch(`/users/${userId}/avatar`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
if (response.status === 404) {app.flash('Not Found', 'error'); reject(response);}
app.flash(`Avatar marked for deletion`);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
modal.open();
});
}
static deleteJobRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job;
try {
job = app.data.users[userId].jobs[jobId];
} catch (error) {
job = {};
}
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Job deletion</h4>
<p>Do you really want to delete the Job <b>${job?.title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
<a class="action-button btn modal-close red waves-effect waves-light" data-action="confirm">Delete</a>
</div>
</div>
`
);
document.querySelector('#modals').appendChild(modalElement);
let modal = M.Modal.init(
modalElement,
{
dismissible: false,
onCloseEnd: () => {
modal.destroy();
modalElement.remove();
}
}
);
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let jobTitle = job?.title;
fetch(`/jobs/${jobId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
@ -280,9 +423,7 @@ class Utils {
static getJobLogRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job = app.data.users[userId].jobs[jobId];
fetch(`/jobs/${job.id}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}})
fetch(`/jobs/${jobId}/log`, {method: 'GET', headers: {Accept: 'application/json, text/plain'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
@ -296,7 +437,7 @@ class Utils {
)
.then(
(text) => {
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
@ -328,14 +469,19 @@ class Utils {
static restartJobRequest(userId, jobId) {
return new Promise((resolve, reject) => {
let job = app.data.users[userId].jobs[jobId];
let job;
try {
job = app.data.users[userId].jobs[jobId];
} catch (error) {
job = {};
}
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm Job restart</h4>
<p>Do you really want to restart the Job <b>${job.title}</b>? All Job Results will be permanently deleted.</p>
<p>Do you really want to restart the Job <b>${job?.title}</b>? All Job Results will be permanently deleted.</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -358,8 +504,8 @@ class Utils {
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let jobTitle = job.title;
fetch(`/jobs/${job.id}/restart`, {method: 'POST', headers: {Accept: 'application/json'}})
let jobTitle = job?.title;
fetch(`/jobs/${jobId}/restart`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
@ -380,14 +526,19 @@ class Utils {
static deleteUserRequest(userId) {
return new Promise((resolve, reject) => {
let user = app.data.users[userId];
let user;
try {
user = app.data.users[userId];
} catch (error) {
user = {};
}
let modalElement = Utils.elementFromString(
let modalElement = Utils.HTMLToElement(
`
<div class="modal">
<div class="modal-content">
<h4>Confirm User deletion</h4>
<p>Do you really want to delete the User <b>${user.username}</b>? All files will be permanently deleted!</p>
<p>Do you really want to delete the User <b>${user?.username}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a class="action-button btn modal-close waves-effect waves-light" data-action="cancel">Cancel</a>
@ -410,8 +561,8 @@ class Utils {
let confirmElement = modalElement.querySelector('.action-button[data-action="confirm"]');
confirmElement.addEventListener('click', (event) => {
let userName = user.username;
fetch(`/users/${user.id}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
let userName = user?.username;
fetch(`/users/${userId}`, {method: 'DELETE', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
@ -429,51 +580,55 @@ class Utils {
});
}
static shareTesseractOCRPipelineModelRequest(userId, tesseractOCRPipelineModelId, shared) {
static tesseractOCRPipelineModelToggleIsPublicRequest(userId, tesseractOCRPipelineModelId, is_public) {
return new Promise((resolve, reject) => {
let tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
let msg = '';
if (shared) {
msg = `Model "${tesseractOCRPipelineModel.title}" is now public`;
} else {
msg = `Model "${tesseractOCRPipelineModel.title}" is now private`;
let tesseractOCRPipelineModel;
try {
tesseractOCRPipelineModel = app.data.users[userId].tesseract_ocr_pipeline_models[tesseractOCRPipelineModelId];
} catch (error) {
tesseractOCRPipelineModel = {};
}
fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModel.id}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
app.flash(msg);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
fetch(`/contributions/tesseract-ocr-pipeline-models/${tesseractOCRPipelineModelId}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {
app.flash('Forbidden', 'error');
reject(response);
}
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
static shareSpaCyNLPPipelineModelRequest(userId, spaCyNLPPipelineModelId, shared) {
static spaCyNLPPipelineModelToggleIsPublicRequest(userId, spaCyNLPPipelineModelId) {
return new Promise((resolve, reject) => {
let spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
let msg = '';
if (shared) {
msg = `Model "${spaCyNLPPipelineModel.title}" is now public`;
} else {
msg = `Model "${spaCyNLPPipelineModel.title}" is now private`;
let spaCyNLPPipelineModel;
try {
spaCyNLPPipelineModel = app.data.users[userId].spacy_nlp_pipeline_models[spaCyNLPPipelineModelId];
} catch (error) {
spaCyNLPPipelineModel = {};
}
fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModel.id}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {app.flash('Forbidden', 'error'); reject(response);}
app.flash(msg);
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
fetch(`/contributions/spacy-nlp-pipeline-models/${spaCyNLPPipelineModelId}/toggle-public-status`, {method: 'POST', headers: {Accept: 'application/json'}})
.then(
(response) => {
if (response.status === 403) {
app.flash('Forbidden', 'error');
reject(response);
}
resolve(response);
},
(response) => {
app.flash('Something went wrong', 'error');
reject(response);
}
);
});
}
}

View File

@ -0,0 +1,102 @@
/**
* XMLtoObject - Converts XML into a JavaScript value or object.
* GitHub: https://github.com/Pevtrick/XMLtoObject
* by Patrick Jentsch: https://github.com/Pevtrick
*/
/**
* The XMLDocument.toObject() method converts the XMLDocument into a JavaScript value or object.
* @param {String} [attributePrefix=] - A Prefix, which is added to all properties generated by XML attributes.
* @returns {Object} - The converted result.
*/
XMLDocument.prototype.toObject = function(attributePrefix='') {
let obj = {};
obj[this.documentElement.nodeName] = this.documentElement.toObject(attributePrefix);
return obj;
};
/**
* The Node.toObject() method converts the Node into a JavaScript value or object.
* @param {String} [attributePrefix=] - A Prefix, which is added to all properties generated by XML attributes.
* @returns {Object|String|null} - The converted result.
*/
Node.prototype.toObject = function(attributePrefix='') {
let obj = null;
switch (this.nodeType) {
case Node.ELEMENT_NODE:
let hasAttributes = this.attributes.length > 0;
let hasChildNodes = this.childNodes.length > 0;
/* Stop conversion if the Node doesn't contain any attributes or child nodes */
if (!(hasAttributes || hasChildNodes)) {
break;
}
obj = {};
/* Convert attributes */
for (let attribute of this.attributes) {
obj[`attributePrefix${attribute.name}`] = attribute.value;
}
/* Convert child nodes */
for (let childNode of this.childNodes) {
switch (childNode.nodeType) {
case Node.ELEMENT_NODE:
break;
case Node.TEXT_NODE:
/* Check whether the child text node is the only child of the current node. */
if (!hasAttributes && this.childNodes.length === 1) {
obj = childNode.toObject(attributePrefix);
continue;
}
if (childNode.data.trim() === '') {continue;}
break;
default:
/* This recursion leads to a console message. */
childNode.toObject(attributePrefix);
continue;
}
/**
* If the child node is the first of its type in this childset,
* process it and add it directly as a property to the return object.
* If not add it to an array which is set as a property of the return object.
*/
if (childNode.nodeName in obj) {
if (!Array.isArray(obj[childNode.nodeName])) {
obj[childNode.nodeName] = [obj[childNode.nodeName]];
}
obj[childNode.nodeName].push(childNode.toObject(attributePrefix));
} else {
obj[childNode.nodeName] = childNode.toObject(attributePrefix);
}
}
break;
case Node.TEXT_NODE:
if (this.data.trim() !== '') {obj = this.data;}
break;
case Node.COMMENT_NODE:
console.log('Skipping comment node:');
console.log(node);
break;
case Node.DOCUMENT_NODE:
obj = {};
obj[this.documentElement.nodeName] = this.documentElement.toObject(attributePrefix);
break;
default:
/**
* The following node types are not processed because they don't offer data, which has to be stored in the object:
* Node.PROCESSING_INSTRUCTION_NODE, Node.DOCUMENT_TYPE_NODE, Node.DOCUMENT_FRAGMENT_NODE
* The following node types are deprecated and therefore not supported by this function:
* Node.ATTRIBUTE_NODE, Node.CDATA_SECTION_NODE, Node.ENTITY_REFERENCE_NODE, Node.ENTITY_NODE, Node.NOTATION_NODE
*/
console.log(`Node type: '${this.nodeType}' is not supported.`);
console.log(node);
break;
}
return obj;
}

View File

@ -29,7 +29,8 @@
<ul class="dropdown-content" id="nav-more-dropdown">
<li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons left">help</i>Manual</a></li>
{% if current_user.is_authenticated %}
<li><a href="{{ url_for('settings.settings') }}"><i class="material-icons left">settings</i>Settings</a></li>
<li><a href="{{ url_for('settings.settings') }}"><i class="material-icons left">settings</i>General settings</a></li>
<li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">contact_page</i>Profile settings</a></li>
<li class="divider" tabindex="-1"></li>
<li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
{% else %}

View File

@ -1,6 +1,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/fast-json-patch/3.1.1/fast-json-patch.min.js" integrity="sha512-5uDdefwnzyq4N+SkmMBmekZLZNmc6dLixvVxCdlHBfqpyz0N3bzLdrJ55OLm7QrZmgZuhLGgHLDtJwU6RZoFCA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/list.js/2.3.1/list.min.js" integrity="sha512-93wYgwrIFL+b+P3RvYxi/WUFRXXUDSLCT2JQk9zhVGXuS2mHl2axj6d+R6pP+gcU5isMHRj1u0oYE/mWyt/RjA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.1/socket.io.min.js" integrity="sha512-mHO4BJ0ELk7Pb1AzhTi3zvUeRgq3RXVOu9tTRfnA6qOxGK4pG2u57DJYolI4KrEnnLTcH9/J5wNOozRTDaybXg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.4/socket.io.min.js" integrity="sha512-HTENHrkQ/P0NGDFd5nk6ibVtCkcM7jhr2c7GyvXp5O+4X6O5cQO9AhqFzM+MdeBivsX7Hoys2J7pp2wdgMpCvw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
{%- assets
filters='rjsmin',
output='gen/app.%(version)s.js',
@ -18,15 +18,17 @@
'js/RessourceDisplays/RessourceDisplay.js',
'js/RessourceDisplays/CorpusDisplay.js',
'js/RessourceDisplays/JobDisplay.js',
'js/RessourceLists/RessourceList.js',
'js/RessourceLists/CorpusList.js',
'js/RessourceLists/CorpusFileList.js',
'js/RessourceLists/JobList.js',
'js/RessourceLists/JobInputList.js',
'js/RessourceLists/JobResultList.js',
'js/RessourceLists/SpacyNLPPipelineModelList.js',
'js/RessourceLists/TesseractOCRPipelineModelList.js',
'js/RessourceLists/UserList.js'
'js/ResourceLists/ResourceList.js',
'js/ResourceLists/CorpusFileList.js',
'js/ResourceLists/CorpusList.js',
'js/ResourceLists/JobList.js',
'js/ResourceLists/JobInputList.js',
'js/ResourceLists/JobResultList.js',
'js/ResourceLists/SpacyNLPPipelineModelList.js',
'js/ResourceLists/TesseractOCRPipelineModelList.js',
'js/ResourceLists/UserList.js',
'js/ResourceLists/AdminUserList.js',
'js/XMLtoObject.js'
%}
<script src="{{ ASSET_URL }}"></script>
{%- endassets %}
@ -36,32 +38,34 @@
const currentUserId = {{ current_user.hashid|tojson }};
// Initialize components for current user
app.subscribeUser(currentUserId).catch((error) => {throw JSON.stringify(error);});
app.getUser(currentUserId, true, true);
app.subscribeUser(currentUserId)
.catch((error) => {throw JSON.stringify(error);});
app.getUser(currentUserId, true, true)
.catch((error) => {throw JSON.stringify(error);});
{%- endif %}
// Disable all option elements with no value
for (let optionElementWithoutValue of document.querySelectorAll('option[value=""]')) {
optionElementWithoutValue.disabled = true;
for (let optionElement of document.querySelectorAll('option[value=""]')) {
optionElement.disabled = true;
}
// Set the data-length attribute on inputs with the maxlength attribute
for (let inputElement of document.querySelectorAll('input[maxlength]')) {
// Set the data-length attribute on textareas/inputs with the maxlength attribute
for (let inputElement of document.querySelectorAll('textarea[maxlength], input[maxlength]')) {
inputElement.dataset.length = inputElement.getAttribute('maxlength');
}
// Initialize components
M.AutoInit();
M.CharacterCounter.init(document.querySelectorAll('input[data-length][type="text"], input[data-length][type="email"], input[data-length][type="search"], input[data-length][type="password"], input[data-length][type="tel"], input[data-length][type="url"]'));
M.CharacterCounter.init(document.querySelectorAll('input[data-length], textarea[data-length]'));
M.Dropdown.init(
document.querySelectorAll('#nav-more-dropdown-trigger'),
{alignment: 'right', constrainWidth: false, coverTrigger: false}
);
RessourceList.autoInit();
ResourceList.autoInit();
Form.autoInit();
// Display flashed messages
for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {
app.flash(flashedMessage[1], flashedMessage[0]);
for (let [category, message] of {{ get_flashed_messages(with_categories=True)|tojson }}) {
app.flash(message, message);
}
</script>

View File

@ -1,12 +1,20 @@
<ul class="sidenav sidenav-fixed" id="sidenav">
<li>
<div class="user-view">
<div class="user-view" style="padding-top: 8px;">
<div class="background primary-color"></div>
<span class="white-text name">{{ current_user.username }}</span>
<span class="white-text email">{{ current_user.email }}</span>
<div class="row">
<div class="col s2">
<a href="{{ url_for('users.user', user_id=current_user.id) }}">
<i class="material-icons" style="color:white; font-size:3em; margin-top: 25px; margin-left:-15px;">account_circle</i></div>
</a>
<div class="col s10">
<span class="white-text name">{{ current_user.username }}</span>
<span class="white-text email">{{ current_user.email }}</span>
</div>
</div>
</div>
</li>
<li><a href="{{ url_for('main.index') }}">nopaque</a></li>
{# <li><a href="{{ url_for('main.index') }}">nopaque</a></li> #}
<li><a href="{{ url_for('main.news') }}"><i class="material-icons left">email</i>News</a></li>
<li><a href="{{ url_for('main.user_manual') }}"><i class="material-icons">help</i>Manual</a></li>
<li><a href="{{ url_for('main.dashboard') }}"><i class="material-icons">dashboard</i>Dashboard</a></li>
@ -15,16 +23,17 @@
<li><a href="{{ url_for('contributions.contributions') }}"><i class="material-icons">new_label</i>Contribute</a></li>
<li><div class="divider"></div></li>
<li><a class="subheader">Processes & Services</a></li>
<li class="service-color service-color-border border-darken" data-service="file-setup-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.file_setup_pipeline') }}"><i class="nopaque-icons service-icon" data-service="file-setup-pipeline"></i>File setup</a></li>
<li class="service-color service-color-border border-darken" data-service="tesseract-ocr-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.tesseract_ocr_pipeline') }}"><i class="nopaque-icons service-icon" data-service="tesseract-ocr-pipeline"></i>OCR</a></li>
<li class="service-color service-color-border border-darken" data-service="file-setup-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.file_setup_pipeline') }}"><i class="nopaque-icons service-icons" data-service="file-setup-pipeline"></i>File setup</a></li>
<li class="service-color service-color-border border-darken" data-service="tesseract-ocr-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.tesseract_ocr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="tesseract-ocr-pipeline"></i>OCR</a></li>
{% if config.NOPAQUE_TRANSKRIBUS_ENABLED %}
<li class="service-color service-color-border border-darken" data-service="transkribus-htr-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.transkribus_htr_pipeline') }}"><i class="nopaque-icons service-icon" data-service="transkribus-htr-pipeline"></i>HTR</a></li>
<li class="service-color service-color-border border-darken" data-service="transkribus-htr-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.transkribus_htr_pipeline') }}"><i class="nopaque-icons service-icons" data-service="transkribus-htr-pipeline"></i>HTR</a></li>
{% endif %}
<li class="service-color service-color-border border-darken" data-service="spacy-nlp-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.spacy_nlp_pipeline') }}"><i class="nopaque-icons service-icon" data-service="spacy-nlp-pipeline"></i>NLP</a></li>
<li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icon" data-service="corpus-analysis"></i>Corpus analysis</a></li>
<li class="service-color service-color-border border-darken" data-service="spacy-nlp-pipeline" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.spacy_nlp_pipeline') }}"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>NLP</a></li>
<li class="service-color service-color-border border-darken" data-service="corpus-analysis" style="border-left: 10px solid; margin-top: 5px;"><a href="{{ url_for('services.corpus_analysis') }}"><i class="nopaque-icons service-icons" data-service="corpus-analysis"></i>Corpus analysis</a></li>
<li><div class="divider"></div></li>
<li><a class="subheader">Account</a></li>
<li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>Settings</a></li>
<li><a href="{{ url_for('settings.settings') }}"><i class="material-icons">settings</i>General Settings</a></li>
<li><a href="{{ url_for('users.edit_profile', user_id=current_user.id) }}"><i class="material-icons left">contact_page</i>Profile settings</a></li>
<li><a href="{{ url_for('auth.logout') }}">Log out</a></li>
{% if current_user.can(Permission.ADMINISTRATE) or current_user.can(Permission.USE_API) %}
<li><div class="divider"></div></li>

View File

@ -11,16 +11,16 @@
<div class="col s12">
<form method="POST">
{{ edit_general_settings_form.hidden_tag() }}
{{ edit_profile_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">General settings</span>
{{ wtf.render_field(edit_general_settings_form.username, material_icon='person') }}
{{ wtf.render_field(edit_general_settings_form.email, material_icon='email') }}
{{ wtf.render_field(edit_profile_settings_form.username, material_icon='person') }}
{{ wtf.render_field(edit_profile_settings_form.email, material_icon='email') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_general_settings_form.submit, material_icon='send') }}
{{ wtf.render_field(edit_profile_settings_form.submit, material_icon='send') }}
</div>
</div>
</form>

View File

@ -11,7 +11,7 @@
<div class="col s12">
<div class="card">
<div class="card-content">
<div class="user-list no-autoinit"></div>
<div class="admin-user-list no-autoinit" id="admin-user-list"></div>
</div>
</div>
</div>
@ -22,11 +22,8 @@
{% block scripts %}
{{ super() }}
<script>
for (let user of {{ json_users|tojson }}) {
if (user.id in app.data.users) {continue;}
app.data.users[user.id] = user;
}
let userList = new UserList(document.querySelector('.user-list'));
userList.init(app.data.users);
let adminUserListElement = document.querySelector('#admin-user-list');
let adminUserList = new AdminUserList(adminUserListElement);
adminUserList.add({{ users|tojson }});
</script>
{% endblock scripts %}

View File

@ -13,7 +13,7 @@
<div class="card extension-selector hoverable service-color" data-service="tesseract-ocr-pipeline">
<a href="{{ url_for('.tesseract_ocr_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
<div class="card-content">
<span class="card-title" data-service="tesseract-ocr-pipeline"><i class="nopaque-icons service-icon" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline Models</span>
<span class="card-title" data-service="tesseract-ocr-pipeline"><i class="nopaque-icons service-icons" data-service="tesseract-ocr-pipeline"></i>Tesseract OCR Pipeline Models</span>
<p>Here you can see and edit the models that you have created. You can also create new models.</p>
</div>
</div>
@ -23,7 +23,7 @@
<div class="card extension-selector hoverable service-color" data-service="spacy-nlp-pipeline">
<a href="{{ url_for('.spacy_nlp_pipeline_models') }}" style="position: absolute; width: 100%; height: 100%;"></a>
<div class="card-content">
<span class="card-title"><i class="nopaque-icons service-icon" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline Models</span>
<span class="card-title"><i class="nopaque-icons service-icons" data-service="spacy-nlp-pipeline"></i>SpaCy NLP Pipeline Models</span>
<p>Here you can see and edit the models that you have created. You can also create new models.</p>
</div>
</div>
@ -34,7 +34,7 @@
<div class="card extension-selector hoverable service-color" data-service="transkribus-htr-pipeline">
<a href="" style="position: absolute; width: 100%; height: 100%;"></a>
<div class="card-content">
<span class="card-title"><i class="nopaque-icons service-icon" data-service="transkribus-htr-pipeline"></i>Transkribus HTR Pipeline Models</span>
<span class="card-title"><i class="nopaque-icons service-icons" data-service="transkribus-htr-pipeline"></i>Transkribus HTR Pipeline Models</span>
<p>Here you can see and edit the models that you have created. You can also create new models.</p>
</div>
</div>

View File

@ -16,7 +16,7 @@
<p class="hide-on-small-only">&nbsp;</p>
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="spacy-nlp-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="spacy-nlp-pipeline"></i>
</a>
</div>
</div>

View File

@ -16,7 +16,7 @@
<p class="hide-on-small-only">&nbsp;</p>
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="tesseract-ocr-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="tesseract-ocr-pipeline"></i>
</a>
</div>
</div>

View File

@ -6,7 +6,7 @@
<div class="row">
<div class="input-field col s12 m9">
<i class="material-icons prefix">search</i>
<input class="validate corpus-analysis-action" id="concordance-extension-form-query" name="query" type="text" required pattern=".*\S+.*" placeholder="&lt;ent_type=&quot;PERSON&quot;&gt; []* &lt;/ent_type&gt; []* [simple_pos=&quot;VERB&quot;] :: match.text_publishing_year=&quot;1991&quot;;"></input>
<input class="validate corpus-analysis-action" id="concordance-extension-form-query" name="query" type="text" required pattern=".*\S+.*" placeholder="Type in your query or use the Query Builder on the right"</input>
<label for="concordance-extension-form-query">Query</label>
<span class="error-color-text helper-text hide" id="concordance-extension-error"></span>
<a class="modal-trigger" href="#cql-tutorial-modal" style="margin-left: 40px;"><i class="material-icons" style="font-size: inherit;">help</i> Corpus Query Language tutorial</a>

View File

@ -4,7 +4,7 @@
{% block page_content %}
<ul class="row tabs no-autoinit" id="corpus-analysis-app-extension-tabs">
<li class="tab col s3"><a class="active" href="#corpus-analysis-app-overview"><i class="nopaque-icons service-icon left" data-service="corpus-analysis"></i>Corpus analysis</a></li>
<li class="tab col s3"><a class="active" href="#corpus-analysis-app-overview"><i class="nopaque-icons service-icons left" data-service="corpus-analysis"></i>Corpus analysis</a></li>
<li class="tab col s3"><a href="#concordance-extension-container"><i class="material-icons left">list_alt</i>Concordance</a></li>
<li class="tab col s3"><a href="#reader-extension-container"><i class="material-icons left">chrome_reader_mode</i>Reader</a></li>
</ul>

View File

@ -0,0 +1,72 @@
{% extends "base.html.j2" %}
{% block main_attribs %} class="service-scheme" data-service="corpus-analysis"{% endblock main_attribs %}
{% block page_content %}
<div class="corpus-list no-autoinit" id="corpus-list">
<div class="parallax-container">
<div class="parallax"><img src="{{ url_for('static', filename='images/parallax_hq/canvas.png') }}"></div>
<div style="position: absolute; bottom: 0; width: 100%;">
<div class="container">
<div class="white-text">
<h1 id="title"><i class="nopaque-icons" style="font-size: inherit;">I</i>Corpora</h1>
</div>
<div class="white" style="padding: 1px 35px 0 10px; border-radius: 35px;">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input class="search" id="corpus-list-search" type="text">
<label for="corpus-list-search">Search corpus</label>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col s12" id="corpora">
<div class="card">
<div class="card-content">
<div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
let corpusListElement = document.querySelector('#corpus-list');
let corpusListOptions = {
initialHtmlGenerator: null,
item: `
<tr class="clickable hoverable">
<td><a class="btn-floating disabled"><i class="material-icons service-color darken" data-service="corpus-analysis">book</i></a></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="status badge new corpus-status-color corpus-status-text" data-badge-caption=""></span></td>
<td class="right-align">
<a class="action-button btn-floating service-color darken waves-effect waves-light" data-action="view" data-service="corpus-analysis"><i class="material-icons">send</i></a>
</td>
</tr>
`.trim(),
};
let corpusList = new CorpusList(corpusListElement, corpusListOptions);
corpusList._init({{ corpora|tojson }});
</script>
{% endblock scripts %}

View File

@ -9,28 +9,19 @@
<div class="row">
<div class="col s12">
<h1 id="title">{{ title }}</h1>
<p>Here you can create a new corpus, just choose a title and a description which will help to identify it later. After the corpus has been created, annotated texts in <i>verticalized text format</i> can be added to it on the corpus overview page.</p>
</div>
<div class="col s12 m4">
<p>Fill out the following form to add a corpus to your corpora.</p>
<a class="waves-effect waves-light btn" href="{{ url_for('main.dashboard') }}"><i class="material-icons left">arrow_back</i>Back to dashboard</a>
</div>
<div class="col s12 m8">
<div class="col s12">
<div class="card">
<form method="POST">
<div class="card-content">
{{ form.hidden_tag() }}
<div class="row">
<div class="col s12 m4">
{{ wtf.render_field(form.title, material_icon='title') }}
</div>
<div class="col s12 m8">
{{ wtf.render_field(form.description, material_icon='description') }}
</div>
</div>
{{ wtf.render_field(form.title, material_icon='title') }}
{{ wtf.render_field(form.description, material_icon='description') }}
</div>
<div class="card-action right-align">
<a class="waves-effect waves-light btn red" href="{{ url_for('main.dashboard', _anchor='corpora') }}"><i class="material-icons left">close</i>Cancel</a>
{{ wtf.render_field(form.submit, material_icon='send') }}
</div>
</form>

View File

@ -36,6 +36,7 @@
</div>
</div>
<div class="card-action right-align">
<a class="waves-effect waves-light btn red" href="{{ url_for('.corpus', corpus_id=corpus.id) }}"><i class="material-icons left">close</i>Cancel</a>
{{ wtf.render_field(form.submit, material_icon='send') }}
</div>
</div>

View File

@ -9,7 +9,7 @@
<div class="col s12" data-job-id="{{ job.hashid }}" data-user-id="{{ job.user.hashid }}" id="job-display">
<div class="row">
<div class="col s8 m9 l10">
<h1 id="title"><i style="font-size: inherit;" class="nopaque-icons service-icon" data-service="{{ job.service }}"></i> <span class="job-title"></span></h1>
<h1 id="title"><i style="font-size: inherit;" class="nopaque-icons service-icons" data-service="{{ job.service }}"></i> <span class="job-title"></span></h1>
</div>
<div class="col s4 m3 l2 right-align">
<p>&nbsp;</p>

View File

@ -57,7 +57,7 @@
<div class="card-panel center-align hoverable">
<br>
<a href="{{ url_for('services.file_setup_pipeline') }}" class="btn-floating btn-large waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icon" data-service="file-setup-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="file-setup-pipeline"></i>
</a>
<br><br>
<p class="service-color-text darken" data-service="file-setup-pipeline"><b>File setup</b></p>
@ -69,7 +69,7 @@
<div class="card-panel center-align hoverable">
<br>
<a href="{{ url_for('services.tesseract_ocr_pipeline') }}" class="btn-floating btn-large waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icon" data-service="tesseract-ocr-pipeline" style="font-size: 2.5rem;"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="tesseract-ocr-pipeline" style="font-size: 2.5rem;"></i>
</a>
<br><br>
<p class="service-color-text darken" data-service="tesseract-ocr-pipeline"><b>Optical Character Recognition</b></p>
@ -81,7 +81,7 @@
<div class="card-panel center-align hoverable">
<br>
<a href="{{ url_for('services.spacy_nlp_pipeline') }}" class="btn-floating btn-large waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icon" data-service="spacy-nlp-pipeline" style="font-size: 2.5rem;"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="spacy-nlp-pipeline" style="font-size: 2.5rem;"></i>
</a>
<br><br>
<p class="service-color-text darken" data-service="spacy-nlp-pipeline"><b>Natural Language Processing</b></p>

View File

@ -0,0 +1,274 @@
{% extends "base.html.j2" %}
{% from "main/_breadcrumbs.html.j2" import breadcrumbs with context %}
{% block page_content %}
<div class="section scrollspy" id="dashboard">
<div class="row">
<div class="col s1"></div>
<div class="col s11">
<h1 id="title">Dashboard</h1>
</div>
<div class="col s1"></div>
<div class="col s3">
<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
</div>
<div class="col s8">
<a class="btn waves-effect waves-light" href="#my-corpora"><i class="nopaque-icons left">I</i>My Corpora</a>
<a class="btn waves-effect waves-light" href="#my-jobs"><i class="nopaque-icons left">J</i>My Jobs</a>
<a class="btn waves-effect waves-light" href="#my-groups"><i class="material-icons left">groups</i>My Groups</a>
</div>
</div>
</div>
<div class="corpus-list no-autoinit" id="corpus-list" data-user-id="{{ current_user.hashid }}">
<div class="parallax-container">
<div class="parallax"><img src="{{ url_for('static', filename='images/parallax_hq/canvas.png') }}"></div>
<div style="position: absolute; bottom: 0; width: 100%;">
<div class="container">
<div class="white" style="padding: 1px 35px 0 10px; border-radius: 35px;">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input class="search" id="corpus-list-search" type="text">
<label for="corpus-list-search">Search Corpus</label>
</div>
</div>
</div>
</div>
</div>
<div class="section scrollspy" id="my-corpora">
<div class="row">
<div class="col s1"></div>
<div class="col s2">
<h2>My Corpora</h2>
<p>Create a corpus to interactively perform linguistic analysis.</p>
<p>Or browse our users public corpora.<span class="new badge"></span></p>
</div>
<div class="col s6">
<div class="card">
<div class="card-content">
<div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
</div>
<div class="card-action right-align">
<a class="btn disabled waves-effect waves-light" href="{{ url_for('corpora.import_corpus') }}">Import Corpus<i class="material-icons right">import_export</i></a>
<a class="btn waves-effect waves-light" href="{{ url_for('corpora.create_corpus') }}">Create corpus<i class="material-icons right">add</i></a>
</div>
</div>
</div>
<div class="col s1"></div>
<div class="col s2">
<ul class="section table-of-contents">
<li><a href="#dashboard">Dashboard</a></li>
<li><a href="#my-corpora">My Corpora</a></li>
<li><a href="#my-jobs">My Jobs</a></li>
<li><a href="#my-groups">My Groups</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="job-list no-autoinit" id="job-list" data-user-id="{{ current_user.hashid }}">
<div class="parallax-container">
<div class="parallax"><img src="{{ url_for('static', filename='images/parallax_hq/canvas.png') }}"></div>
<div style="position: absolute; bottom: 0; width: 100%;">
<div class="container">
<div class="white" style="padding: 1px 35px 0 10px; border-radius: 35px;">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input class="search" id="job-list-search" type="text">
<label for="job-list-search">Search Job</label>
</div>
</div>
</div>
</div>
</div>
<div class="section scrollspy" id="my-jobs">
<div class="row">
<div class="col s1"></div>
<div class="col s2">
<h2>My Jobs</h2>
<p>
A job is the execution of a service provided by nopaque. You can
create any number of jobs and let them be processed simultaneously. We
<b>strongly recommend</b> that you create a folder on your computer where you
save the various files that nopaque provides you with after each
pre-processing step. You will need the result of each step for the
next step.
</p>
<p><b>Where is my Job data?</b> Don't worry, please read <a href="{{ url_for('main.news', _anchor='april-2022-update') }}">this news</a> entry</p>
</div>
<div class="col s6">
<div class="card">
<div class="card-content">
<div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
</div>
<div class="card-action right-align">
<a class="btn modal-trigger waves-effect waves-light" data-target="create-job-modal"><i class="material-icons left">add</i>Create job</a>
</div>
</div>
</div>
<div class="col s1"></div>
<div class="col s2">
<ul class="section table-of-contents">
<li><a href="#dashboard">Dashboard</a></li>
<li><a href="#my-corpora">My Corpora</a></li>
<li><a href="#my-jobs">My Jobs</a></li>
<li><a href="#my-groups">My Groups</a></li>
</ul>
</div>
</div>
</div>
</div>
<div class="group-list no-autoinit" id="group-list" data-user-id="{{ current_user.hashid }}">
<div class="parallax-container">
<div class="parallax"><img src="{{ url_for('static', filename='images/parallax_hq/canvas.png') }}"></div>
<div style="position: absolute; bottom: 0; width: 100%;">
<div class="container">
<div class="white" style="padding: 1px 35px 0 10px; border-radius: 35px;">
<div class="input-field">
<i class="material-icons prefix">search</i>
<input class="search" id="group-list-search" type="text">
<label for="group-list-search">Search Group</label>
</div>
</div>
</div>
</div>
</div>
<div class="section scrollspy" id="my-groups">
<div class="row">
<div class="col s1"></div>
<div class="col s2">
<h2>My Groups</h2>
<p>Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.</p>
</div>
<div class="col s6">
<div class="card">
<div class="card-content">
<div>
<table>
<thead>
<tr>
<th></th>
<th>Title and Description</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody class="list"></tbody>
</table>
<ul class="pagination"></ul>
</div>
</div>
<div class="card-action right-align">
<a class="btn waves-effect waves-light"><i class="material-icons left">add</i>Create group</a>
</div>
</div>
</div>
<div class="col s1"></div>
<div class="col s2">
<ul class="section table-of-contents">
<li><a href="#dashboard">Dashboard</a></li>
<li><a href="#my-corpora">My Corpora</a></li>
<li><a href="#my-jobs">My Jobs</a></li>
<li><a href="#my-groups">My Groups</a></li>
</ul>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block modals %}
{{ super() }}
<div id="create-job-modal" class="modal">
<div class="modal-content">
<h4>Select a service</h4>
<p>&nbsp;</p>
<div class="row">
<div class="col s12 m4">
<div class="card-panel center-align hoverable">
<br>
<a href="{{ url_for('services.file_setup_pipeline') }}" class="btn-floating btn-large waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icons" data-service="file-setup-pipeline"></i>
</a>
<br><br>
<p class="service-color-text darken" data-service="file-setup-pipeline"><b>File setup</b></p>
<p class="light">Digital copies of text based research data (books, letters, etc.) often comprise various files and formats. nopaque converts and merges those files to facilitate further processing.</p>
<a href="{{ url_for('services.file_setup_pipeline') }}" class="waves-effect waves-light btn service-color darken" data-service="file-setup-pipeline">Create Job</a>
</div>
</div>
<div class="col s12 m4">
<div class="card-panel center-align hoverable">
<br>
<a href="{{ url_for('services.tesseract_ocr_pipeline') }}" class="btn-floating btn-large waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icons" data-service="tesseract-ocr-pipeline" style="font-size: 2.5rem;"></i>
</a>
<br><br>
<p class="service-color-text darken" data-service="tesseract-ocr-pipeline"><b>Optical Character Recognition</b></p>
<p class="light">nopaque converts your image data like photos or scans into text data through a process called OCR. This step enables you to proceed with further computational analysis of your documents.</p>
<a href="{{ url_for('services.tesseract_ocr_pipeline') }}" class="waves-effect waves-light btn service-color darken" data-service="tesseract-ocr-pipeline">Create Job</a>
</div>
</div>
<div class="col s12 m4">
<div class="card-panel center-align hoverable">
<br>
<a href="{{ url_for('services.spacy_nlp_pipeline') }}" class="btn-floating btn-large waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icons" data-service="spacy-nlp-pipeline" style="font-size: 2.5rem;"></i>
</a>
<br><br>
<p class="service-color-text darken" data-service="spacy-nlp-pipeline"><b>Natural Language Processing</b></p>
<p class="light">By means of computational linguistic data processing (tokenization, lemmatization, part-of-speech tagging and named-entity recognition) nopaque extracts additional information from your text.</p>
<a href="{{ url_for('services.spacy_nlp_pipeline') }}" class="waves-effect waves-light btn service-color darken" data-service="spacy-nlp-pipeline">Create Job</a>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<a class="btn-flat modal-close waves-effect waves-light">Close</a>
</div>
</div>
{% endblock modals %}
{% block scripts %}
{{ super() }}
<script>
let corpusListElement = document.querySelector('#corpus-list');
let corpusListOptions = {initialHtmlGenerator: null};
let corpusList = new CorpusList(corpusListElement, corpusListOptions);
let jobListElement = document.querySelector('#job-list');
let jobListOptions = {initialHtmlGenerator: null};
let jobList = new JobList(jobListElement, jobListOptions);
</script>
{% endblock scripts %}

View File

@ -77,7 +77,7 @@
<div class="col s12 m6 l3 center-align">
<p>&nbsp;</p>
<a href="{{ url_for('services.file_setup_pipeline') }}" class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="file-setup-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="file-setup-pipeline"></i>
</a>
<br><br>
<p class="service-color-text text-darken" data-service="file-setup-pipeline"><b>File setup</b></p>
@ -86,7 +86,7 @@
<div class="col s12 m6 l3 center-align">
<p>&nbsp;</p>
<a href="{{ url_for('services.tesseract_ocr_pipeline') }}" class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="tesseract-ocr-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="tesseract-ocr-pipeline"></i>
</a>
<br><br>
<p class="service-color-text text-darken" data-service="tesseract-ocr-pipeline"><b>Optical Character Recognition</b></p>
@ -95,7 +95,7 @@
<div class="col s12 m6 l3 center-align">
<p>&nbsp;</p>
<a href="{{ url_for('services.transkribus_htr_pipeline') }}" class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="transkribus-htr-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="transkribus-htr-pipeline"></i>
</a>
<br><br>
<p class="service-color-text text-darken" data-service="transkribus-htr-pipeline"><b>Transkribus HTR Pipeline</b></p>
@ -104,7 +104,7 @@
<div class="col s12 m6 l3 center-align">
<p>&nbsp;</p>
<a href="{{ url_for('services.spacy_nlp_pipeline') }}" class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="spacy-nlp-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="spacy-nlp-pipeline"></i>
</a>
<br><br>
<p class="service-color-text text-darken" data-service="spacy-nlp-pipeline"><b>Natural Language Processing</b></p>
@ -113,7 +113,7 @@
<div class="col s12 m6 l3 center-align">
<p>&nbsp;</p>
<a href="{{ url_for('services.corpus_analysis') }}" class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="corpus-analysis"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="corpus-analysis"></i>
</a>
<br><br>
<p class="service-color-text text-darken" data-service="corpus-analysis"><b>Corpus analysis</b></p>

View File

@ -7,7 +7,7 @@
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<div class="card" id="april-2022-update">
<div class="card-content">

View File

@ -13,7 +13,7 @@
<div class="col s12 m3 push-m9">
<div class="center-align">
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light" style="transform: scale(2);">
<i class="nopaque-icons service-color darken service-icon" data-service="corpus-analysis"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="corpus-analysis"></i>
</a>
</div>
</div>

View File

@ -16,7 +16,7 @@
<p class="hide-on-small-only">&nbsp;</p>
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="file-setup-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="file-setup-pipeline"></i>
</a>
</div>
</div>

View File

@ -16,7 +16,7 @@
<p class="hide-on-small-only">&nbsp;</p>
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="spacy-nlp-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="spacy-nlp-pipeline"></i>
</a>
</div>
</div>

View File

@ -16,7 +16,7 @@
<p class="hide-on-small-only">&nbsp;</p>
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="tesseract-ocr-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="tesseract-ocr-pipeline"></i>
</a>
</div>
</div>
@ -85,6 +85,7 @@
</label>
</div>
</div>
{% endif %}
{% if 'disabled' not in form.ocropus_nlbin_threshold.render_kw or not form.ocropus_nlbin_threshold.render_kw['disabled'] %}
<div class="col s9 hide" id="create-job-form-ocropus_nlbin_threshold-container">
<br>
@ -92,7 +93,6 @@
<p class="range-field">{{ form.ocropus_nlbin_threshold() }}</p>
</div>
{% endif %}
{% endif %}
<!--
Seperate each setting with the following
<div class="col s12"><p>&nbsp;</p></div>

View File

@ -16,7 +16,7 @@
<p class="hide-on-small-only">&nbsp;</p>
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn-floating btn-large btn-scale-x2 waves-effect waves-light">
<i class="nopaque-icons service-color darken service-icon" data-service="transkribus-htr-pipeline"></i>
<i class="nopaque-icons service-color darken service-icons" data-service="transkribus-htr-pipeline"></i>
</a>
</div>
</div>

View File

@ -8,69 +8,52 @@
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<form method="POST">
{{ edit_general_settings_form.hidden_tag() }}
{{ edit_notification_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">General settings</span>
{{ wtf.render_field(edit_general_settings_form.username, material_icon='person') }}
{{ wtf.render_field(edit_general_settings_form.email, material_icon='email') }}
<span class="card-title">Notification settings</span>
{{ wtf.render_field(edit_notification_settings_form.job_status_mail_notification_level, material_icon='notifications') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_general_settings_form.submit, material_icon='send') }}
{{ wtf.render_field(edit_notification_settings_form.submit, material_icon='send') }}
</div>
</div>
</form>
</div>
</form>
<form method="POST">
{{ edit_notification_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Notification settings</span>
{{ wtf.render_field(edit_notification_settings_form.job_status_mail_notification_level, material_icon='notifications') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_notification_settings_form.submit, material_icon='send') }}
</form>
<form method="POST">
{{ change_password_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Change Password</span>
{{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(change_password_form.submit, material_icon='send') }}
</div>
</div>
</div>
</div>
</form>
</form>
<form method="POST">
{{ change_password_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Change Password</span>
{{ wtf.render_field(change_password_form.password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password, material_icon='vpn_key') }}
{{ wtf.render_field(change_password_form.new_password_2, material_icon='vpn_key') }}
<span class="card-title">Delete account</span>
<p>Deleting an account has the following effects:</p>
<ul>
<li>All data associated with your corpora and jobs will be permanently deleted.</li>
<li>All settings will be permanently deleted.</li>
</ul>
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(change_password_form.submit, material_icon='send') }}
</div>
<div class="card-action right-align">
<a class="btn red waves-effect waves-light" id="delete-user"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
</form>
<div class="card">
<div class="card-content">
<span class="card-title">Delete account</span>
<p>Deleting an account has the following effects:</p>
<ul>
<li>All data associated with your corpora and jobs will be permanently deleted.</li>
<li>All settings will be permanently deleted.</li>
</ul>
</div>
<div class="card-action right-align">
<a class="btn red waves-effect waves-light" id="delete-user"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>
</div>
</div>

View File

@ -1,117 +0,0 @@
<div class="row" id="concordance-extension-container">
<div class="col s12">
<div class="card">
<div class="card-content">
<form id="concordance-extension-form">
<div class="row">
<div class="input-field col s12 m9">
<i class="material-icons prefix">search</i>
<input class="validate corpus-analysis-action" id="concordance-extension-form-query" name="query" type="text"
required pattern=".*\S+.*"
placeholder="&lt;ent_type=&quot;PERSON&quot;&gt; []* &lt;/ent_type&gt; []* [simple_pos=&quot;VERB&quot;] :: match.text_publishing_year=&quot;1991&quot;;">
</input>
<label for="concordance-extension-form-query">Query</label>
<span class="error-color-text helper-text hide" id="concordance-extension-error"></span>
<a class="modal-trigger" href="#cql-tutorial-modal" style="margin-left: 40px;"><i class="material-icons" style="font-size: inherit;">help</i>
Corpus Query Language tutorial</a>
<span> | </span>
<a class="modal-trigger" href="#tagsets-modal"><i class="material-icons" style="font-size: inherit;">info</i> Tagsets</a>
</div>
<div class="input-field col s12 m3">
<i class="material-icons prefix">arrow_forward</i>
<input class="validate corpus-analysis-action" id="concordance-extension-form-subcorpus-name" name="subcorpus-name" type="text"
required pattern="^[A-Z][a-z0-9\-]*" value="Last">
</input>
<label for="concordance-extension-form-subcorpus-name">Subcorpus name</label>
</div>
<div class="col s12 m9 l9">
<div class="row">
<div class="input-field col s4 l3">
<i class="material-icons prefix">short_text</i>
<select class="corpus-analysis-action" name="context">
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
<option value="30">30</option>
</select>
<label>Context</label>
</div>
<div class="input-field col s4 l3">
<i class="material-icons prefix">format_list_numbered</i>
<select class="corpus-analysis-action" name="per-page">
<option value="10" selected>10</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
</select>
<label>Matches per page</label>
</div>
<div class="input-field col s4 l3">
<i class="material-icons prefix">format_shapes</i>
<select name="text-style">
<option value="0">Plain text</option>
<option value="1" selected>Highlight entities</option>
<option value="2">Token text</option>
</select>
<label>Text style</label>
</div>
<div class="input-field col s4 l3">
<i class="material-icons prefix">format_quote</i>
<select name="token-representation">
<option value="lemma">lemma</option>
<option value="pos">pos</option>
<option value="simple_pos">simple_pos</option>
<option value="word" selected>word</option>
</select>
<label>Token representation</label>
</div>
</div>
</div>
<div class="col s12 m3 l3 right-align">
<p class="hide-on-small-only">&nbsp;</p>
<a class="btn waves-effect waves-light modal-trigger" href="#concordance-query-builder" id="concordance-query-builder-button">
<i class="material-icons left">build</i>
Query builder
</a>
<button class="btn waves-effect waves-light corpus-analysis-action" id="concordance-extension-form-submit" type="submit" name="submit">
Send
<i class="material-icons right">send</i>
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col s12">
<div id="concordance-extension-subcorpus-list"></div>
<div class="card">
<div class="card-content">
<div class="progress hide" id="concordance-extension-progress">
<div class="indeterminate"></div>
</div>
<div class="row">
<div class="col s9"><p class="hide" id="concordance-extension-subcorpus-info"></p></div>
<div class="col s3 right-align" id="concordance-extension-subcorpus-actions"></div>
</div>
<table class="highlight">
<thead>
<tr>
<th style="width: 2%;"></th>
<th style="width: 8%;">Source</th>
<th class="right-align" style="width: 22.5%;">Left context</th>
<th class="center-align" style="width: 40%;">KWIC</th>
<th class="left-align" style="width: 22.5%;">Right Context</th>
<th class="left-align" style="width: 5%;"></th>
</tr>
</thead>
<tbody id="concordance-extension-subcorpus-items"></tbody>
</table>
<ul class="pagination hide" id="concordance-extension-subcorpus-pagination"></ul>
</div>
</div>
</div>
</div>

View File

@ -1,454 +0,0 @@
{% extends "base.html.j2" %}
{% import "materialize/wtf.html.j2" as wtf %}
<style>
a {color: #FFFFFF;}
</style>
{% block main_attribs %} class="service-scheme" data-service="corpus-analysis" id="corpus-analysis-app-container"{% endblock main_attribs %}
{% block page_content %}
<ul class="row tabs no-autoinit" id="corpus-analysis-app-extension-tabs">
<li class="tab col s3"><a href="#corpus-analysis-app-overview"><i class="nopaque-icons service-icon left" data-service="corpus-analysis"></i>Corpus analysis</a></li>
<li class="tab col s3"><a class="active" href="#concordance-extension-container"><i class="material-icons left">list_alt</i>Concordance</a></li>
<li class="tab col s3"><a href="#reader-extension-container"><i class="material-icons left">chrome_reader_mode</i>Reader</a></li>
</ul>
{# <div class="row" id="corpus-analysis-app-overview">
<div class="col s12">
<h1>{{ title }}</h1>
</div>
<div class="col s3">
<div class="card extension-selector hoverable" data-target="concordance-extension-container">
<div class="card-content">
<span class="card-title"><i class="material-icons left">list_alt</i>Concordance</span>
<p>Query your corpus with the CQP query language utilizing a KWIC view.</p>
</div>
</div>
</div>
<div class="col s3">
<div class="card extension-selector hoverable" data-target="reader-extension-container">
<div class="card-content">
<span class="card-title"><i class="material-icons left">chrome_reader_mode</i>Reader</span>
<p>Inspect your corpus in detail with a full text view, including annotations.</p>
</div>
</div>
</div>
</div> #}
{% include "test/analyse_corpus.concordance.html.j2" %}
{% endblock page_content %}
{% block modals %}
{{ super() }}
<div class="modal no-autoinit" id="corpus-analysis-app-init-modal">
<div class="modal-content">
<h4>Initializing session...</h4>
<p>If the loading takes to long or an error occured,
<a onclick="window.location.reload()" href="#">click here</a>
to refresh your session or
<a href="">go back</a>!
</p>
<div class="progress" id="corpus-analysis-app-init-progress">
<div class="indeterminate"></div>
</div>
<p class="error-color-text hide" id="corpus-analysis-app-init-error"></p>
</div>
</div>
<div class="modal" id="cql-tutorial-modal">
<div class="modal-content">
{% with headline_num=4 %}
{% include "main/manual/_08_cqp_query_language.html.j2" %}
{% endwith %}
</div>
</div>
<div class="modal" id="tagsets-modal">
<div class="modal-content">
<h4>Tagsets</h4>
<ul class="tabs">
<li class="tab"><a class="active" href="#simple_pos-tagset">simple_pos</a></li>
<li class="tab"><a href="#english-ent_type-tagset">English ent_type</a></li>
<li class="tab"><a href="#english-pos-tagset">English pos</a></li>
<li class="tab"><a href="#german-ent_type-tagset">German ent_type</a></li>
<li class="tab"><a href="#german-pos-tagset">German pos</a></li>
</ul>
{% include "main/manual/_10_tagsets.html.j2" %}
</div>
</div>
<div class="modal" id="concordance-query-builder">
<div class="modal-content">
<div>
<nav>
<div class="nav-wrapper" id="query-builder-nav">
<a href="#!" class="brand-logo"><i class="material-icons">build</i>Query Builder</a>
<i class="material-icons close right" id="close-query-builder">close</i>
<a class="modal-trigger" href="#query-builder-tutorial-modal" >
<i class="material-icons right tooltipped" id="query-builder-tutorial-info-icon" data-position="bottom" data-tooltip="Click here if you are unsure how to use the Query Builder <br>and want to find out what other options it offers.">help</i>
</a>
</div>
</nav>
</div>
<p></p>
<div id="query-container" class="hide">
<div class="row">
<h6 class="col s2">Your Query:
<a class="modal-trigger" href="#query-builder-tutorial-modal">
<i class="material-icons left" id="general-options-query-builder-tutorial-info-icon">help_outline</i></a>
</h6>
</div>
<div class="row">
<div class="col s10" id="your-query" data-position="bottom" data-tooltip="You can edit your query by deleting individual elements or moving them via drag and drop."></div>
<a class="btn-small waves-effect waves-teal col s1" id="insert-query-button">
<i class="material-icons">send</i>
</a>
</div>
<p><i> Preview:</i></p>
<p id="query-preview"></p>
<br>
</div>
<h6>Use the following options to build your query. If you need help, click on the question mark in the upper right corner!</h6>
<p></p>
<a class="btn-large waves-effect waves-light tooltipped" id="positional-attr-button" data-position="bottom" data-tooltip="Search for any token, for example a word, a lemma or a part-of-speech tag">Add new token to your query</a>
<a class="btn-large waves-effect waves-light tooltipped" id="structural-attr-button" data-position="bottom" data-tooltip="Structure your query with structural attributes, for example sentences, entities or annotate the text">Add structural attributes to your query</a>
<div id="structural-attr" class="hide">
<p></p>
<h6>Which structural attribute do you want to add to your query?<a class="modal-trigger" href="#query-builder-tutorial-modal"><i class="material-icons left" id="add-structural-attribute-tutorial-info-icon">help_outline</i></a></h6>
<p></p>
<div class="row">
<div class="col s12">
<a class="btn-small waves-effect waves-light" id="sentence">sentence</a>
<a class="btn-small waves-effect waves-light" id="entity">entity</a>
<a class="btn-small waves-effect waves-light" id="text-annotation">Meta Data</a>
</div>
</div>
<div id="entity-builder" class="hide">
<p></p>
<br>
<div class="row">
<a class="btn waves-effect waves-light col s4" id="empty-entity">Add Entity of any type</a>
<p class="col s1 l1"></p>
<div class= "input-field col s3">
<select name="englishenttype" id="english-ent-type">
<option value="" disabled selected>English ent_type</option>
<option value="CARDINAL">CARDINAL</option>
<option value="DATE">DATE</option>
<option value="EVENT">EVENT</option>
<option value="FAC">FAC</option>
<option value="GPE">GPE</option>
<option value="LANGUAGE">LANGUAGE</option>
<option value="LAW">LAW</option>
<option value="LOC">LOC</option>
<option value="MONEY">MONEY</option>
<option value="NORP">NORP</option>
<option value="ORDINAL">ORDINAL</option>
<option value="ORG">ORG</option>
<option value="PERCENT">PERCENT</option>
<option value="PERSON">PERSON</option>
<option value="PRODUCT">PRODUCT</option>
<option value="QUANTITY">QUANTITY</option>
<option value="TIME">TIME</option>
<option value="WORK_OF_ART">WORK_OF_ART</option>
</select>
<label>Entity Type</label>
</div>
<div class= "input-field col s3">
<select name="germanenttype" id="german-ent-type">
<option value="" disabled selected>German ent_type</option>
<option value="LOC">LOC</option>
<option value="MISC">MISC</option>
<option value="ORG">ORG</option>
<option value="PER">PER</option>
</select>
</div>
</div>
</div>
<div id="text-annotation-builder" class="hide">
<p></p>
<br>
<div class="row">
<div class= "input-field col s4 l3">
<select name="text-annotation-options" id="text-annotation-options">
<option class="btn-small waves-effect waves-light" value="address">address</option>
<option class="btn-small waves-effect waves-light" value="author">author</option>
<option class="btn-small waves-effect waves-light" value="booktitle">booktitle</option>
<option class="btn-small waves-effect waves-light" value="chapter">chapter</option>
<option class="btn-small waves-effect waves-light" value="editor">editor</option>
<option class="btn-small waves-effect waves-light" value="institution">institution</option>
<option class="btn-small waves-effect waves-light" value="journal">journal</option>
<option class="btn-small waves-effect waves-light" value="pages">pages</option>
<option class="btn-small waves-effect waves-light" value="publisher">publisher</option>
<option class="btn-small waves-effect waves-light" value="publishing_year">publishing year</option>
<option class="btn-small waves-effect waves-light" value="school">school</option>
<option class="btn-small waves-effect waves-light" value="title">title</option>
</select>
<label>Meta data</label>
</div>
<div class= "input-field col s7 l5">
<i class="material-icons prefix">mode_edit</i>
<input placeholder="Type in your text annotation" type="text" id="text-annotation-input">
</div>
<div class="col s1 l1 center-align">
<p class="btn-floating waves-effect waves-light" id="text-annotation-submit">
<i class="material-icons right">send</i>
</p>
</div>
<div class="hide" id="no-value-metadata-message"><i>No value entered!</i></div>
</div>
</div>
</div>
<div id="positional-attr" class="hide">
<p></p>
<div class="row" id="token-kind-selector">
<div class="col s5">
<h6>Which kind of token are you looking for? <a class="modal-trigger" href="#query-builder-tutorial-modal"><i class="material-icons left" id="token-tutorial-info-icon">help_outline</i></a></h6>
</div>
<div class="input-field col s3">
<select id="token-attr">
<option value="word" selected>word</option>
<option value="lemma">lemma</option>
<option value="english-pos">english pos</option>
<option value="german-pos">german pos</option>
<option value="simple-pos-button">simple_pos</option>
<option value="empty-token">empty token</option>
</select>
</div>
</div>
<p></p>
<div id="token-builder-content">
<div class="row" >
<div id="token-query"></div>
<div id="word-builder">
<div class= "input-field col s3 l4">
<i class="material-icons prefix">mode_edit</i>
<input placeholder="Type in your word" type="text" id="word-input">
</div>
</div>
<div id="lemma-builder" class="hide" >
<div class= "input-field col s3 l4">
<i class="material-icons prefix">mode_edit</i>
<input placeholder="Type in your lemma" type="text" id="lemma-input">
</div>
</div>
<div id="english-pos-builder" class="hide">
<div class="col s6 m4 l4">
<div class="row">
<div class= "input-field col s12">
<select name="englishpos" id="english-pos">
<option value="default" disabled selected>English pos tagset</option>
<option value="ADD">email</option>
<option value="AFX">affix</option>
<option value="CC">conjunction, coordinating</option>
<option value="CD">cardinal number</option>
<option value="DT">determiner</option>
<option value="EX">existential there</option>
<option value="FW">foreign word</option>
<option value="HYPH">punctuation mark, hyphen</option>
<option value="IN">conjunction, subordinating or preposition</option>
<option value="JJ">adjective</option>
<option value="JJR">adjective, comparative</option>
<option value="JJS">adjective, superlative</option>
</select>
<label>Part-of-speech tags</label>
</div>
</div>
</div>
</div>
<div id="german-pos-builder" class="hide">
<div class="col s6 m4 l4">
<div class="row">
<div class= "input-field col s12">
<select name="germanpos" id="german-pos">
<option value="default" disabled selected>German pos tagset</option>
<option value="ADJA">adjective, attributive</option>
<option value="ADJD">adjective, adverbial or predicative</option>
<option value="ADV">adverb</option>
<option value="APPO">postposition</option>
<option value="APPR">preposition; circumposition left</option>
<option value="APPRART">preposition with article</option>
<option value="APZR">circumposition right</option>
<option value="ART">definite or indefinite article</option>
</select>
<label>Part-of-speech tags</label>
</div>
</div>
</div>
</div>
<div id="simplepos-builder" class="hide">
<div class="col s6 m4 l4">
<div class="row">
<div class= "input-field col s12">
<select name="simplepos" id="simple-pos">
<option value="default" disabled selected>simple_pos tagset</option>
<option value="ADJ">adjective</option>
<option value="ADP">adposition</option>
<option value="ADV">adverb</option>
<option value="AUX">auxiliary verb</option>
<option value="CONJ">coordinating conjunction</option>
<option value="DET">determiner</option>
<option value="INTJ">interjection</option>
<option value="NOUN">noun</option>
<option value="NUM">numeral</option>
<option value="PART">particle</option>
<option value="PRON">pronoun</option>
<option value="PROPN">proper noun</option>
<option value="PUNCT">punctuation</option>
<option value="SCONJ">subordinating conjunction</option>
<option value="SYM">symbol</option>
<option value="VERB">verb</option>
<option value="X">other</option>
</select>
<label>Simple part-of-speech tags</label>
</div>
</div>
</div>
</div>
<div class="col s1 l1 center-align">
<p class="btn-floating waves-effect waves-light" id="token-submit">
<i class="material-icons right">send</i>
</p>
</div>
<div class="hide" id="no-value-message"><i>No value entered!</i></div>
</div>
<div id="token-edit-options">
<div class="row">
<h6>Options to edit your token: <a class="modal-trigger" href="#query-builder-tutorial-modal"><i class="material-icons left" id="edit-options-tutorial-info-icon">help_outline</i></a></h6>
</div>
<p></p>
<div class="row">
<div id="input-options" class="col s5 m5 l5 xl4">
<a id="wildcard-char" class="btn-small waves-effect waves-light tooltipped" data-position="top" data-tooltip="Look for a variable character (also called wildcard character)">Wildcard character</a>
<a id="option-group" class="btn-small waves-effect waves-light tooltipped" data-position="top" data-tooltip="Find character sequences from a list of options">Option Group</a>
</div>
<div class="col s3 m3 l3 xl3" id="incidence-modifiers-button">
<a class="dropdown-trigger btn-small waves-effect waves-light" href="#" data-target="incidence-modifiers" data-position="top" data-tooltip="Incidence Modifiers are special characters or patterns, <br>which determine how often a character represented previously should occur.">incidence modifiers</a>
</div>
<ul id="incidence-modifiers" class="dropdown-content">
<li><a id="one-or-more" data-token="+" class="tooltipped" data-position ="top" data-tooltip="...occurrences of the character/token before">one or more (+)</a></li>
<li><a id="zero-or-more" data-token="*" class="tooltipped" data-position ="top" data-tooltip="...occurrences of the character/token before">zero or more (*)</a></li>
<li><a id="zero-or-one" data-token="?" class="tooltipped" data-position ="top" data-tooltip="...occurrences of the character/token before">zero or one (?)</a></li>
<li><a id="exactly-n" class="modal-trigger tooltipped" href="#exactlyN" data-token="{n}" class="" data-position ="top" data-tooltip="...occurrences of the character/token before">exactly n ({n})</a></li>
<li><a id="between-n-m" class="modal-trigger tooltipped" href="#betweenNM" data-token="{n,m}" class="" data-position ="top" data-tooltip="...occurrences of the character/token before">between n and m ({n,m})</a></li>
</ul>
<div id="ignore-case-checkbox" class="col s2 m2 l2 xl2">
<p id="ignore-case">
<label>
<input type="checkbox" class="filled-in" />
<span>Ignore Case</span>
</label>
</p>
</div>
<div class="col s2 m2 l2 xl2" id="condition-container">
<a class="btn-small tooltipped waves-effect waves-light" id="or" data-position="bottom" data-tooltip="You can add another condition to your token. <br>At least one must be fulfilled">or</a>
<a class="btn-small tooltipped waves-effect waves-light" id="and" data-position="bottom" data-tooltip="You can add another condition to your token. <br>Both must be fulfilled">and</a>
</div>
</div>
</div>
</div>
<div id="exactlyN" class="modal">
<div class="row modal-content">
<div class="input-field col s10">
<i class="material-icons prefix">mode_edit</i>
<input placeholder="type in a number for 'n'" type="text" id="n-input">
</div>
<div class="col s2">
<p class="btn-floating waves-effect waves-light" id="n-submit">
<i class="material-icons right">send</i>
</p>
</div>
</div>
</div>
<div id="betweenNM" class="modal">
<div class="row modal-content">
<div class= "input-field col s5">
<i class="material-icons prefix">mode_edit</i>
<input placeholder="number for 'n'" type="text" id="n-m-input">
</div>
<div class= "input-field col s5">
<i class="material-icons prefix">mode_edit</i>
<input placeholder="number for 'm'" type="text" id="m-input">
</div>
<div class="col s2">
<p class="btn-floating waves-effect waves-light" id="n-m-submit">
<i class="material-icons right">send</i>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal modal-fixed-footer" id="query-builder-tutorial-modal">
<div class="modal-content" >
<div id="query-builder-tutorial-start"></div>
<ul class="tabs">
<li class="tab"><a class="active" href="#query-builder-tutorial">Query Builder Tutorial</a></li>
{# <li class="tab"><a href="#qb-examples">Examples</a></li> #}
<li class="tab"><a href="#cql-cb-tutorial">Corpus Query Language Tutorial</a></li>
<li class="tab"><a href="#tagsets-cb-tutorial">Tagsets</a></li>
</ul>
<div id="query-builder-tutorial">
{% include "main/manual/_09_query_builder.html.j2" %}
</div>
{# <div id="qb-examples"></div> #}
<div id ="cql-cb-tutorial">
{% with headline_num=4 %}
{% include "main/manual/_08_cqp_query_language.html.j2" %}
{% endwith %}
</div>
<div id="tagsets-cb-tutorial">
<h4>Tagsets</h4>
{% include "main/manual/_10_tagsets.html.j2" %}
</div>
<div class="fixed-action-btn">
<a class="btn-floating btn-large teal" id="scroll-up-button-query-builder-tutorial" href='#query-builder-tutorial-start'>
<i class="large material-icons">arrow_upward</i>
</a>
</div>
</div>
</div>
{% endblock modals %}
{% block scripts %}
{{ super() }}
<script>
const concordanceQueryBuilder = new ConcordanceQueryBuilder()
</script>
{% endblock scripts %}

View File

@ -0,0 +1,154 @@
{% extends "base.html.j2" %}
{% import "materialize/wtf.html.j2" as wtf %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12">
<h1 id="title">{{ title }}</h1>
</div>
<div class="col s12">
<div class="card">
<form method="POST" enctype="multipart/form-data">
<div class="card-content">
<div class="row">
<div class="col s6">
{{ edit_profile_settings_form.hidden_tag() }}
{{ wtf.render_field(edit_profile_settings_form.username, material_icon='person') }}
{{ wtf.render_field(edit_profile_settings_form.email, material_icon='email') }}
</div>
</div>
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_profile_settings_form.submit, material_icon='send') }}
</div>
</div>
</form>
</div>
<form method="POST">
{{ edit_privacy_settings_form.hidden_tag() }}
<div class="card">
<div class="card-content">
<span class="card-title">Privacy settings</span>
<br>
{{ wtf.render_field(edit_privacy_settings_form.is_public, id='public-profile') }}
<br>
<hr>
<br>
{{ wtf.render_field(edit_privacy_settings_form.show_email, data_action='disable', disabled=true) }}
<br>
{{ wtf.render_field(edit_privacy_settings_form.show_last_seen, data_action='disable', disabled=true) }}
<br>
{{ wtf.render_field(edit_privacy_settings_form.show_member_since, data_action='disable', disabled=true) }}
<br>
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_privacy_settings_form.submit, material_icon='send') }}
</div>
</div>
</div>
</form>
<div class="card">
<form method="POST" enctype="multipart/form-data">
<div class="card-content">
{{ edit_public_profile_information_form.hidden_tag() }}
<div class="row">
<div class="col s12">
<span class="card-title">Public Profile</span>
</div>
</div>
<div class="row">
<div class="col s5">
<div class="row">
<div class="col s2"></div>
<div class="col s8">
{% if user.avatar %}
<img src="{{ url_for('.profile_avatar', user_id=user.id) }}" alt="user-image" class="circle responsive-img" id="avatar">
{% else %}
<img src="{{ url_for('static', filename='images/user_avatar.png') }}" alt="user-image" class="circle responsive-img" id="avatar">
{% endif %}
</div>
<div class="col s2"></div>
</div>
<div class="row">
<div class="col s2">
<div class="center-align">
<a class="action-button btn-floating red waves-effect waves-light" style="margin-top:15px;" id="delete-avatar"><i class="material-icons">delete</i></a>
</div>
</div>
<div class="col s10">
{{ wtf.render_field(edit_public_profile_information_form.avatar, accept='image/jpeg, image/png, image/gif', placeholder='Choose an image file', id='avatar-upload') }}
</div>
</div>
</div>
<div class="col s7">
<p></p>
<br>
{{ wtf.render_field(edit_public_profile_information_form.full_name, material_icon='badge') }}
{{ wtf.render_field(edit_public_profile_information_form.about_me, material_icon='description') }}
{{ wtf.render_field(edit_public_profile_information_form.website, material_icon='laptop') }}
{{ wtf.render_field(edit_public_profile_information_form.organization, material_icon='business') }}
{{ wtf.render_field(edit_public_profile_information_form.location, material_icon='location_on') }}
</div>
</div>
</div>
<div class="card-action">
<div class="right-align">
{{ wtf.render_field(edit_public_profile_information_form.submit, material_icon='send') }}
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
let publicProfile = document.querySelector('#public-profile');
let disableButtons = document.querySelectorAll('[data-action="disable"]');
let deleteButton = document.querySelector('#delete-avatar');
let avatar = document.querySelector('#avatar');
let avatarUpload = document.querySelector('#avatar-upload');
for (let disableButton of disableButtons) {
disableButton.disabled = !publicProfile.checked;
}
publicProfile.addEventListener('change', () => {
if (publicProfile.checked) {
for (let disableButton of disableButtons) {
disableButton.disabled = false;
}
} else {
for (let disableButton of disableButtons) {
disableButton.checked = false;
disableButton.disabled = true;
}
}
});
avatarUpload.addEventListener('change', function() {
let file = this.files[0];
avatar.src = URL.createObjectURL(file);
});
deleteButton.addEventListener('click', () => {
Utils.deleteProfileAvatarRequest({{ user.hashid|tojson }})
.then(
(response) => {
avatar.src = "{{ url_for('static', filename='images/user_avatar.png') }}";
}
);
});
</script>
{% endblock scripts %}

View File

@ -0,0 +1,114 @@
{% extends "base.html.j2" %}
{% import "materialize/wtf.html.j2" as wtf %}
{% block page_content %}
<div class="container">
<div class="row">
<div class="col s12">
<div class="card">
<div class="card-content">
<div class="row">
<div class="col s1"></div>
<div class="col s3">
<br>
<br>
{% if user.avatar %}
<img src="{{ url_for('.profile_avatar', user_id=user_id) }}" alt="user-image" class="circle responsive-img">
{% else %}
<img src="{{ url_for('static', filename='images/user_avatar.png') }}" alt="user-image" class="circle responsive-img">
{% endif %}
</div>
<div class="col s1"></div>
<div class="col s7">
<div class="row">
<div class="col s12">
<h3 style="float:left">{{ user.username }}<span class="new badge green" id="public-information-badge" data-badge-caption="" style="margin-top:20px;"></span></h3>
</div>
<div class="col 12">
{% if user.show_last_seen %}
<div class="chip">Last seen: {{ user.last_seen }}</div>
{% endif %}
{% if user.location %}
<p><span class="material-icons" style="margin-right:20px; margin-top:20px;">location_on</span><i>{{ user.location }}</i></p>
{% endif %}
<p></p>
<br>
{% if user.about_me %}
<div style="border-left: solid 3px #E91E63; padding-left: 15px;">
<h5>About me</h5>
<p>{{ user.about_me }}</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<p></p>
<br>
<div class="row">
<div class="col s1"></div>
<div class="col s8">
<table>
{% if user.full_name %}
<tr>
<td><span class="material-icons">person</span></td>
<td>{{ user.full_name }} </td>
</tr>
{% endif %}
{% if user.show_email %}
<tr>
<td><span class="material-icons">email</span></td>
<td>{{ user.email }}</td>
</tr>
{% endif %}
{% if user.website %}
<tr>
<td><span class="material-icons">laptop</span></td>
<td><a href="{{ user.website }}">{{ user.website }}</a></td>
</tr>
{% endif %}
{% if user.organization %}
<tr>
<td><span class="material-icons">business</span></td>
<td>{{ user.organization }}</td>
</tr>
{% endif %}
</table>
<br>
{% if user.show_member_since %}
<p><i>Member since: {{ user.member_since }}</i></p>
{% endif %}
<p></p>
<br>
{% if current_user.is_authenticated and current_user.hashid == user.id %}
<a class="waves-effect waves-light btn-small" href="{{ url_for('.edit_profile', user_id=current_user.id) }}">Edit profile</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock page_content %}
{% block scripts %}
{{ super() }}
<script>
let publicInformationBadge = document.querySelector('#public-information-badge');
if ("{{ user.id }}" == "{{ current_user.hashid }}") {
if ("{{ user.is_public }}" == "True") {
publicInformationBadge.dataset.badgeCaption = 'Your profile is public';
publicInformationBadge.classList.add('green');
publicInformationBadge.classList.remove('red');
} else {
publicInformationBadge.dataset.badgeCaption = 'Your profile is private';
publicInformationBadge.classList.add('red');
publicInformationBadge.classList.remove('green');
}
} else {
publicInformationBadge.remove();
}
</script>
{% endblock scripts %}

View File

@ -1,5 +0,0 @@
from flask import Blueprint
bp = Blueprint('test', __name__)
from . import routes

View File

@ -1,10 +0,0 @@
from flask import render_template
from flask_login import login_required
from app.models import Corpus, CorpusFile, CorpusStatus
from . import bp
import os
@bp.route('')
@login_required
def test():
return render_template('test/analyse_corpus.html.j2', title="Test")

View File

@ -5,17 +5,55 @@ from app.decorators import socketio_login_required
from app.models import User
@socketio.on('GET /users/<user_id>')
@socketio_login_required
def get_user(user_hashid, backrefs=False, relationships=False):
user_id = hashids.decode(user_hashid)
user = User.query.get(user_id)
if user is None:
return {'status': 404, 'statusText': 'Not found'}
if not (user == current_user or current_user.is_administrator):
return {'status': 403, 'statusText': 'Forbidden'}
return {
'body': user.to_json_serializeable(
backrefs=backrefs,
relationships=relationships
),
'status': 200,
'statusText': 'OK',
}
# @socketio.on('GET /users/<user_id>')
# @socketio_login_required
# def get_user(user_hashid):
# user_id = hashids.decode(user_hashid)
# user = User.query.get(user_id)
# if user is None:
# return {'options': {'status': 404, 'statusText': 'Not found'}}
# if not (user == current_user or current_user.is_administrator):
# return {'options': {'status': 403, 'statusText': 'Forbidden'}}
# return {
# 'body': user.to_json_serializable2(),
# 'options': {
# 'status': 200,
# 'statusText': 'OK',
# 'headers': {'Content-Type: application/json'}
# }
# }
@socketio.on('SUBSCRIBE /users/<user_id>')
@socketio_login_required
def subscribe_user(user_hashid):
user_id = hashids.decode(user_hashid)
user = User.query.get(user_id)
if user is None:
return {'code': 404, 'msg': 'Not found'}
return {'status': 404, 'statusText': 'Not found'}
if not (user == current_user or current_user.is_administrator):
return {'code': 403, 'msg': 'Forbidden'}
return {'status': 403, 'statusText': 'Forbidden'}
join_room(f'/users/{user.hashid}')
return {'code': 200, 'msg': 'OK'}
return {'status': 200, 'statusText': 'OK'}
@socketio.on('UNSUBSCRIBE /users/<user_id>')
@ -24,8 +62,8 @@ def unsubscribe_user(user_hashid):
user_id = hashids.decode(user_hashid)
user = User.query.get(user_id)
if user is None:
return {'code': 404, 'msg': 'Not found'}
return {'status': 404, 'statusText': 'Not found'}
if not (user == current_user or current_user.is_administrator):
return {'code': 403, 'msg': 'Forbidden'}
return {'status': 403, 'statusText': 'Forbidden'}
leave_room(f'/users/{user.hashid}')
return {'code': 200, 'msg': 'OK'}
return {'status': 200, 'statusText': 'OK'}

100
app/users/forms.py Normal file
View File

@ -0,0 +1,100 @@
from flask_wtf import FlaskForm
from wtforms import (
BooleanField,
FileField,
StringField,
SubmitField,
TextAreaField,
ValidationError
)
from wtforms.validators import (
DataRequired,
Email,
Length,
Regexp
)
from app.models import User
from app.auth import USERNAME_REGEX
from app.wtf_validators import FileSizeLimit
class EditProfileSettingsForm(FlaskForm):
email = StringField(
'E-Mail',
validators=[DataRequired(), Length(max=254), Email()]
)
username = StringField(
'Username',
validators=[
DataRequired(),
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 EditPublicProfileInformationForm(FlaskForm):
avatar = FileField(
'Image File',
[FileSizeLimit(max_size_in_mb=2)]
)
full_name = StringField(
'Full name',
validators=[Length(max=128)]
)
about_me = TextAreaField(
'About me',
validators=[
Length(max=254)
]
)
website = StringField(
'Website',
validators=[
Length(max=254)
]
)
organization = StringField(
'Organization',
validators=[
Length(max=128)
]
)
location = StringField(
'Location',
validators=[
Length(max=128)
]
)
submit = SubmitField()
def validate_image_file(self, field):
if not field.data.filename.lower().endswith('.jpg' or '.png' or '.jpeg'):
raise ValidationError('only .jpg, .png and .jpeg!')
class EditPrivacySettingsForm(FlaskForm):
is_public = BooleanField('Public profile')
show_email = BooleanField('Show email')
show_last_seen = BooleanField('Show last seen')
show_member_since = BooleanField('Show member since')
submit = SubmitField()

View File

@ -1,22 +1,41 @@
from flask import abort, current_app, request
from flask import (
abort,
current_app,
flash,
Markup,
redirect,
render_template,
send_from_directory,
url_for
)
from flask_login import current_user, login_required
from threading import Thread
import os
from app import db
from app.models import User
from app.models import Avatar, ProfilePrivacySettings, User
from . import bp
from .forms import (
EditPrivacySettingsForm,
EditProfileSettingsForm,
EditPublicProfileInformationForm
)
@bp.before_request
@login_required
def before_request():
pass
@bp.route('/<hashid:user_id>')
@login_required
def user(user_id):
user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator()):
if not user.is_public and user != current_user:
abort(403)
backrefs = request.args.get('backrefs', 'false').lower() == 'true'
relationships = (
request.args.get('relationships', 'false').lower() == 'true')
return user.to_json_serializeable(backrefs=backrefs, relationships=relationships), 200
return render_template(
'users/profile.html.j2',
user=user.to_json_serializeable(),
user_id=user_id
)
@bp.route('/<hashid:user_id>', methods=['DELETE'])
@login_required
@ -36,3 +55,114 @@ def delete_user(user_id):
)
thread.start()
return {}, 202
@bp.route('/<hashid:user_id>')
def profile(user_id):
user = User.query.get_or_404(user_id)
if not user.is_public and user != current_user:
abort(403)
return render_template(
'users/profile.html.j2',
user=user.to_json_serializeable(),
user_id=user_id
)
@bp.route('/<hashid:user_id>/avatar')
def profile_avatar(user_id):
user = User.query.get_or_404(user_id)
if user.avatar is None:
abort(404)
if not user.is_public and not (user == current_user or current_user.is_administrator()):
abort(403)
return send_from_directory(
os.path.dirname(user.avatar.path),
os.path.basename(user.avatar.path),
as_attachment=True,
attachment_filename=user.avatar.filename,
mimetype=user.avatar.mimetype
)
@bp.route('/<hashid:user_id>/avatar', methods=['DELETE'])
def delete_profile_avatar(user_id):
def _delete_avatar(app, avatar_id):
with app.app_context():
avatar = Avatar.query.get(avatar_id)
avatar.delete()
db.session.commit()
user = User.query.get_or_404(user_id)
if user.avatar is None:
abort(404)
thread = Thread(
target=_delete_avatar,
args=(current_app._get_current_object(), user.avatar.id)
)
thread.start()
return {}, 202
@bp.route('/<hashid:user_id>/edit', methods=['GET', 'POST'])
def edit_profile(user_id):
user = User.query.get_or_404(user_id)
if not (user == current_user or current_user.is_administrator()):
abort(403)
edit_profile_settings_form = EditProfileSettingsForm(
current_user,
data=current_user.to_json_serializeable(),
prefix='edit-profile-settings-form'
)
edit_privacy_settings_form = EditPrivacySettingsForm(
data=current_user.to_json_serializeable(),
prefix='edit-privacy-settings-form'
)
edit_public_profile_information_form = EditPublicProfileInformationForm(
data=current_user.to_json_serializeable(),
prefix='edit-public-profile-information-form'
)
if edit_profile_settings_form.validate_on_submit():
current_user.email = edit_profile_settings_form.email.data
current_user.username = edit_profile_settings_form.username.data
db.session.commit()
flash('Profile settings updated')
return redirect(url_for('.user', user_id=user.id))
if edit_privacy_settings_form.submit.data and edit_privacy_settings_form.validate():
current_user.is_public = edit_privacy_settings_form.is_public.data
if edit_privacy_settings_form.show_email.data:
current_user.add_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL)
else:
current_user.remove_profile_privacy_setting(ProfilePrivacySettings.SHOW_EMAIL)
if edit_privacy_settings_form.show_last_seen.data:
current_user.add_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN)
else:
current_user.remove_profile_privacy_setting(ProfilePrivacySettings.SHOW_LAST_SEEN)
if edit_privacy_settings_form.show_member_since.data:
current_user.add_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE)
else:
current_user.remove_profile_privacy_setting(ProfilePrivacySettings.SHOW_MEMBER_SINCE)
db.session.commit()
flash('Your changes have been saved')
return redirect(url_for('.user', user_id=user.id))
if edit_public_profile_information_form.validate_on_submit():
if edit_public_profile_information_form.avatar.data:
try:
Avatar.create(edit_public_profile_information_form.avatar.data, user=current_user)
except (AttributeError, OSError):
abort(500)
current_user.about_me = edit_public_profile_information_form.about_me.data
current_user.location = edit_public_profile_information_form.location.data
current_user.organization = edit_public_profile_information_form.organization.data
current_user.website = edit_public_profile_information_form.website.data
current_user.full_name = edit_public_profile_information_form.full_name.data
db.session.commit()
flash('Profile settings updated')
return redirect(url_for('.user', user_id=user.id))
return render_template(
'users/edit_profile.html.j2',
edit_profile_settings_form=edit_profile_settings_form,
edit_privacy_settings_form=edit_privacy_settings_form,
edit_public_profile_information_form=edit_public_profile_information_form,
user=user,
title='Edit Profile'
)

9
app/wtf_validators.py Normal file
View File

@ -0,0 +1,9 @@
from wtforms.validators import ValidationError
def FileSizeLimit(max_size_in_mb):
max_bytes = max_size_in_mb*1024*1024
def file_length_check(form, field):
if len(field.data.read()) > max_bytes:
raise ValidationError(f"File size must be less than {max_size_in_mb}MB")
field.data.seek(0)
return file_length_check

View File

@ -36,6 +36,7 @@ class Config:
''' # Flask-Hashids '''
HASHIDS_MIN_LENGTH = 16
HASHIDS_SALT=os.environ.get('HASHIDS_SALT')
''' # Flask-Login # '''
REMEMBER_COOKIE_SECURE = \

View File

@ -0,0 +1,43 @@
"""Add profile pages columns to users table
Revision ID: 4820fa2e3ee2
Revises: f2656133df2f
Create Date: 2022-11-30 10:03:49.080576
"""
from alembic import op
import sqlalchemy as sa
revision = '4820fa2e3ee2'
down_revision = 'f2656133df2f'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'users',
sa.Column('full_name', sa.String(length=64), nullable=True)
)
op.add_column(
'users',
sa.Column('about_me', sa.String(length=256), nullable=True)
)
op.add_column(
'users',
sa.Column('location', sa.String(length=64), nullable=True)
)
op.add_column(
'users',
sa.Column('website', sa.String(length=128), nullable=True)
)
op.add_column(
'users',
sa.Column('organization', sa.String(length=128), nullable=True)
)
def downgrade():
op.drop_column('users', 'organization')
op.drop_column('users', 'website')
op.drop_column('users', 'location')
op.drop_column('users', 'about_me')
op.drop_column('users', 'full_name')

View File

@ -0,0 +1,35 @@
"""Add is_public and profile_privacy_settings columns to users table
Revision ID: 5b2a6e590166
Revises: ef6a275f8079
Create Date: 2022-12-12 12:39:09.339847
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5b2a6e590166'
down_revision = 'ef6a275f8079'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'users',
sa.Column('is_public', sa.Boolean(), nullable=True)
)
op.add_column(
'users',
sa.Column('profile_privacy_settings', sa.Integer(), nullable=True)
)
op.execute('UPDATE users SET is_public = false;')
op.execute('UPDATE users SET profile_privacy_settings = 0;')
def downgrade():
op.drop_column('users', 'is_public')
op.drop_column('users', 'profile_privacy_settings')

View File

@ -0,0 +1,36 @@
"""Add corpus follower associations table
Revision ID: 5fe6a6c7870c
Revises: 5b2a6e590166
Create Date: 2023-01-23 08:27:10.169454
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5fe6a6c7870c'
down_revision = '5b2a6e590166'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('corpus_follower_associations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('following_user_id', sa.Integer(), nullable=True),
sa.Column('followed_corpus_id', sa.Integer(), nullable=True),
sa.Column('permissions', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['followed_corpus_id'], ['corpora.id'], ),
sa.ForeignKeyConstraint(['following_user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('corpus_follower_associations')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""Add avatar table and conncect it to users
Revision ID: ef6a275f8079
Revises: 4820fa2e3ee2
Create Date: 2022-12-01 14:23:22.688572
"""
from alembic import op
import sqlalchemy as sa
revision = 'ef6a275f8079'
down_revision = '4820fa2e3ee2'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('avatars',
sa.Column('creation_date', sa.DateTime(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=True),
sa.Column('mimetype', sa.String(length=255), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('avatars')

View File

@ -0,0 +1,41 @@
"""Rename shared columns to is_public and add description column to corpus_files table
Revision ID: f2656133df2f
Revises: 31b9c0259e6b
Create Date: 2022-11-29 14:17:16.845501
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f2656133df2f'
down_revision = '31b9c0259e6b'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'corpus_files',
sa.Column('description', sa.String(length=255), nullable=True)
)
op.alter_column(
'spacy_nlp_pipeline_models',
'shared',
new_column_name='is_public'
)
op.alter_column(
'tesseract_ocr_pipeline_models',
'shared',
new_column_name='is_public'
)
def downgrade():
op.alter_column('tesseract_ocr_pipeline_models', 'is_public', new_column_name='shared')
op.alter_column('spacy_nlp_pipeline_models', 'is_public', new_column_name='shared')
op.drop_column('corpus_files', 'description')

View File

@ -5,8 +5,10 @@ eventlet.monkey_patch()
from app import cli, create_app, db, scheduler, socketio # noqa
from app.models import (
Avatar,
Corpus,
CorpusFile,
CorpusFollowerAssociation,
Job,
JobInput,
JobResult,
@ -34,8 +36,10 @@ def make_context() -> Dict[str, Any]:
def make_shell_context() -> Dict[str, Any]:
''' Adds variables to the shell context. '''
return {
'Avatar': Avatar,
'Corpus': Corpus,
'CorpusFile': CorpusFile,
'CorpusFollowerAssociation': CorpusFollowerAssociation,
'db': db,
'Job': Job,
'JobInput': JobInput,

View File

@ -1,18 +1,19 @@
apifairy
cqi
dnspython==2.2.1
docker
eventlet
Flask==2.1.3
Flask-APScheduler
Flask-Assets
Flask-Hashids
Flask-Hashids==1.0.0
Flask-HTTPAuth
Flask-Login
Flask-Mail
Flask-Migrate
Flask-Paranoid
Flask-SocketIO
Flask-SQLAlchemy
Flask-SQLAlchemy==2.5.1
Flask-WTF
hiredis
MarkupSafe==2.0.1
@ -23,5 +24,6 @@ pyScss
python-dotenv
pyyaml
redis
SQLAlchemy==1.4.46
tqdm
wtforms[email]