From efa8712cd900b47077dd37a704d3699959ba2cc0 Mon Sep 17 00:00:00 2001
From: Patrick Jentsch
Date: Thu, 29 Jun 2023 12:09:28 +0200
Subject: [PATCH] Add a full featured cqi Javascript client for cqi_over_sio
---
app/corpora/__init__.py | 1 +
app/corpora/cqi_over_sio/__init__.py | 109 +++++
app/corpora/cqi_over_sio/cqi.py | 111 +++++
app/corpora/cqi_over_socketio/utils.py | 2 +-
app/static/js/cqi/api/client.js | 598 +++++++++++++++++++++++++
app/static/js/cqi/api/package.js | 1 +
app/static/js/cqi/client.js | 57 +++
app/static/js/cqi/errors.js | 185 ++++++++
app/static/js/cqi/models/attributes.js | 289 ++++++++++++
app/static/js/cqi/models/corpora.js | 127 ++++++
app/static/js/cqi/models/package.js | 1 +
app/static/js/cqi/models/resource.js | 90 ++++
app/static/js/cqi/models/subcorpora.js | 155 +++++++
app/static/js/cqi/package.js | 6 +
app/static/js/cqi/status.js | 51 +++
app/templates/_scripts.html.j2 | 17 +
16 files changed, 1799 insertions(+), 1 deletion(-)
create mode 100644 app/corpora/cqi_over_sio/__init__.py
create mode 100644 app/corpora/cqi_over_sio/cqi.py
create mode 100644 app/static/js/cqi/api/client.js
create mode 100644 app/static/js/cqi/api/package.js
create mode 100644 app/static/js/cqi/client.js
create mode 100644 app/static/js/cqi/errors.js
create mode 100644 app/static/js/cqi/models/attributes.js
create mode 100644 app/static/js/cqi/models/corpora.js
create mode 100644 app/static/js/cqi/models/package.js
create mode 100644 app/static/js/cqi/models/resource.js
create mode 100644 app/static/js/cqi/models/subcorpora.js
create mode 100644 app/static/js/cqi/package.js
create mode 100644 app/static/js/cqi/status.js
diff --git a/app/corpora/__init__.py b/app/corpora/__init__.py
index 34663b69..65129bf6 100644
--- a/app/corpora/__init__.py
+++ b/app/corpora/__init__.py
@@ -17,3 +17,4 @@ def before_request():
from . import cli, cqi_over_socketio, files, followers, routes, json_routes
+from . import cqi_over_sio
diff --git a/app/corpora/cqi_over_sio/__init__.py b/app/corpora/cqi_over_sio/__init__.py
new file mode 100644
index 00000000..5c2131bd
--- /dev/null
+++ b/app/corpora/cqi_over_sio/__init__.py
@@ -0,0 +1,109 @@
+from cqi import CQiClient
+from cqi.errors import CQiException
+from flask import session
+from flask_login import current_user
+from flask_socketio import ConnectionRefusedError
+from threading import Lock
+from app import db, hashids, socketio
+from app.decorators import socketio_login_required
+from app.models import Corpus, CorpusStatus
+
+
+'''
+This package tunnels the Corpus Query interface (CQi) protocol through
+Socket.IO (SIO) by wrapping each CQi function in a seperate SIO event.
+
+This module only handles the SIO connect/disconnect, which handles the setup
+and teardown of necessary ressources for later use. Each CQi function has a
+corresponding SIO event. The event handlers are spread across the different
+modules within this package.
+
+Basic concept:
+1. A client connects to the SIO namespace and provides the id of a corpus to be
+ analysed.
+ 1.1 The analysis session counter of the corpus is incremented.
+ 1.2 A CQiClient and a (Mutex) Lock belonging to it is created.
+ 1.3 Wait until the CQP server is running.
+ 1.4 Connect the CQiClient to the server.
+ 1.5 Save the CQiClient and the Lock in the session for subsequential use.
+2. A client emits an event and may provide a single json object with necessary
+ arguments for the targeted CQi function.
+3. A SIO event handler (decorated with cqi_over_socketio) gets executed.
+ - The event handler function defines all arguments. Hence the client
+ is sent as a single json object, the decorator decomposes it to fit
+ the functions signature. This also includes type checking and proper
+ use of the lock (acquire/release) mechanism.
+4. Wait for more events
+5. The client disconnects from the SIO namespace
+ 1.1 The analysis session counter of the corpus is decremented.
+ 1.2 The CQiClient and (Mutex) Lock belonging to it are teared down.
+'''
+
+
+NAMESPACE = '/cqi_over_sio'
+
+
+from .cqi import * # noqa
+
+
+@socketio.on('connect', namespace=NAMESPACE)
+@socketio_login_required
+def connect(auth):
+ # the auth variable is used in a hacky way. It contains the corpus id for
+ # which a corpus analysis session should be started.
+ corpus_id = hashids.decode(auth['corpus_id'])
+ corpus = Corpus.query.get(corpus_id)
+ if corpus is None:
+ # return {'code': 404, 'msg': 'Not Found'}
+ raise ConnectionRefusedError('Not Found')
+ if not (corpus.user == current_user
+ or current_user.is_following_corpus(corpus)
+ or current_user.is_administrator()):
+ # return {'code': 403, 'msg': 'Forbidden'}
+ raise ConnectionRefusedError('Forbidden')
+ if corpus.status not in [
+ CorpusStatus.BUILT,
+ CorpusStatus.STARTING_ANALYSIS_SESSION,
+ CorpusStatus.RUNNING_ANALYSIS_SESSION,
+ CorpusStatus.CANCELING_ANALYSIS_SESSION
+ ]:
+ # return {'code': 424, 'msg': 'Failed Dependency'}
+ raise ConnectionRefusedError('Failed Dependency')
+ if corpus.num_analysis_sessions is None:
+ corpus.num_analysis_sessions = 0
+ db.session.commit()
+ corpus.num_analysis_sessions = Corpus.num_analysis_sessions + 1
+ db.session.commit()
+ retry_counter = 20
+ while corpus.status != CorpusStatus.RUNNING_ANALYSIS_SESSION:
+ if retry_counter == 0:
+ corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
+ db.session.commit()
+ return {'code': 408, 'msg': 'Request Timeout'}
+ socketio.sleep(3)
+ retry_counter -= 1
+ db.session.refresh(corpus)
+ cqi_client = CQiClient(f'cqpserver_{corpus_id}')
+ session['d'] = {
+ 'corpus_id': corpus_id,
+ 'cqi_client': cqi_client,
+ 'cqi_client_lock': Lock(),
+ }
+ # return {'code': 200, 'msg': 'OK'}
+
+
+@socketio.on('disconnect', namespace=NAMESPACE)
+def disconnect():
+ if 'd' not in session:
+ return
+ session['d']['cqi_client_lock'].acquire()
+ try:
+ session['d']['cqi_client'].api.ctrl_bye()
+ except (BrokenPipeError, CQiException):
+ pass
+ session['d']['cqi_client_lock'].release()
+ corpus = Corpus.query.get(session['d']['corpus_id'])
+ corpus.num_analysis_sessions = Corpus.num_analysis_sessions - 1
+ db.session.commit()
+ session.pop('d')
+ # return {'code': 200, 'msg': 'OK'}
diff --git a/app/corpora/cqi_over_sio/cqi.py b/app/corpora/cqi_over_sio/cqi.py
new file mode 100644
index 00000000..9cbdae67
--- /dev/null
+++ b/app/corpora/cqi_over_sio/cqi.py
@@ -0,0 +1,111 @@
+from cqi import APIClient
+from cqi.errors import CQiException
+from cqi.status import CQiStatus
+from flask import session
+from inspect import signature
+from typing import Callable, Dict, List
+from app import socketio
+from app.decorators import socketio_login_required
+from . import NAMESPACE as ns
+
+
+CQI_API_FUNCTIONS: List[str] = [
+ 'ask_feature_cl_2_3',
+ 'ask_feature_cqi_1_0',
+ 'ask_feature_cqp_2_3',
+ 'cl_alg2cpos',
+ 'cl_attribute_size',
+ 'cl_cpos2alg',
+ 'cl_cpos2id',
+ 'cl_cpos2lbound',
+ 'cl_cpos2rbound',
+ 'cl_cpos2str',
+ 'cl_cpos2struc',
+ 'cl_drop_attribute',
+ 'cl_id2cpos',
+ 'cl_id2freq',
+ 'cl_id2str',
+ 'cl_idlist2cpos',
+ 'cl_lexicon_size',
+ 'cl_regex2id',
+ 'cl_str2id',
+ 'cl_struc2cpos',
+ 'cl_struc2str',
+ 'corpus_alignment_attributes',
+ 'corpus_charset',
+ 'corpus_drop_corpus',
+ 'corpus_full_name',
+ 'corpus_info',
+ 'corpus_list_corpora',
+ 'corpus_positional_attributes',
+ 'corpus_properties',
+ 'corpus_structural_attribute_has_values',
+ 'corpus_structural_attributes',
+ 'cqp_drop_subcorpus',
+ 'cqp_dump_subcorpus',
+ 'cqp_fdist_1',
+ 'cqp_fdist_2',
+ 'cqp_list_subcorpora',
+ 'cqp_query',
+ 'cqp_subcorpus_has_field',
+ 'cqp_subcorpus_size',
+ 'ctrl_bye',
+ 'ctrl_connect',
+ 'ctrl_last_general_error',
+ 'ctrl_ping',
+ 'ctrl_user_abort'
+]
+
+
+@socketio.on('cqi_client.api', namespace=ns)
+@socketio_login_required
+def cqi_over_sio(fn_data):
+ fn_name: str = fn_data['fn_name']
+ fn_args: Dict = fn_data.get('fn_args', {})
+ print(f'{fn_name}({fn_args})')
+ if 'd' not in session:
+ return {'code': 424, 'msg': 'Failed Dependency'}
+ api_client: APIClient = session['d']['cqi_client'].api
+ if fn_name not in CQI_API_FUNCTIONS:
+ return {'code': 400, 'msg': 'Bad Request'}
+ try:
+ fn: Callable = getattr(api_client, fn_name)
+ except AttributeError:
+ return {'code': 400, 'msg': 'Bad Request'}
+ for param in signature(fn).parameters.values():
+ if param.default is param.empty:
+ if param.name not in fn_args:
+ return {'code': 400, 'msg': 'Bad Request'}
+ else:
+ if param.name not in fn_args:
+ continue
+ if type(fn_args[param.name]) is not param.annotation:
+ return {'code': 400, 'msg': 'Bad Request'}
+ session['d']['cqi_client_lock'].acquire()
+ try:
+ return_value = fn(**fn_args)
+ except BrokenPipeError:
+ return_value = {
+ 'code': 500,
+ 'msg': 'Internal Server Error'
+ }
+ except CQiException as e:
+ return_value = {
+ 'code': 502,
+ 'msg': 'Bad Gateway',
+ 'payload': {
+ 'code': e.code,
+ 'desc': e.description,
+ 'msg': e.__class__.__name__
+ }
+ }
+ finally:
+ session['d']['cqi_client_lock'].release()
+ if isinstance(return_value, CQiStatus):
+ payload = {
+ 'code': return_value.code,
+ 'msg': return_value.__class__.__name__
+ }
+ else:
+ payload = return_value
+ return {'code': 200, 'msg': 'OK', 'payload': payload}
diff --git a/app/corpora/cqi_over_socketio/utils.py b/app/corpora/cqi_over_socketio/utils.py
index bdab8b53..14e71e2b 100644
--- a/app/corpora/cqi_over_socketio/utils.py
+++ b/app/corpora/cqi_over_socketio/utils.py
@@ -49,7 +49,7 @@ def cqi_over_socketio(f):
'payload': {
'code': e.code,
'desc': e.description,
- 'msg': e.name
+ 'msg': e.__class__.__name__
}
}
finally:
diff --git a/app/static/js/cqi/api/client.js b/app/static/js/cqi/api/client.js
new file mode 100644
index 00000000..5cecf72c
--- /dev/null
+++ b/app/static/js/cqi/api/client.js
@@ -0,0 +1,598 @@
+cqi.api.APIClient = class APIClient {
+ constructor(host, corpus_id, version = '0.1') {
+ this.host = host;
+ this.version = version;
+ this.socket = io(
+ this.host,
+ {
+ auth: {corpus_id: corpus_id},
+ transports: ['websocket'],
+ upgrade: false
+ }
+ );
+ }
+
+ /**
+ * @param {string} fn_name
+ * @param {object} [fn_args={}]
+ * @returns {Promise}
+ */
+ #request(fn_name, fn_args = {}) {
+ return new Promise((resolve, reject) => {
+ this.socket.emit('cqi_client.api', {fn_name: fn_name, fn_args: fn_args}, (response) => {
+ if (response.code === 200) {
+ resolve(response.payload);
+ }
+ if (response.code === 500) {
+ reject(new Error(`[${response.code}] ${response.msg}`));
+ }
+ if (response.code === 502) {
+ reject(new cqi.errors.lookup[response.payload.code]());
+ }
+ });
+ });
+ }
+
+ /**
+ * @param {string} username
+ * @param {string} password
+ * @returns {Promise}
+ */
+ async ctrl_connect(username, password) {
+ const fn_name = 'ctrl_connect';
+ const fn_args = {username: username, password: password};
+ let payload = await this.#request(fn_name, fn_args);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ctrl_bye() {
+ const fn_name = 'ctrl_bye';
+ let payload = await this.#request(fn_name);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ctrl_user_abort() {
+ const fn_name = 'ctrl_user_abort';
+ return await this.#request(fn_name);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ctrl_ping() {
+ const fn_name = 'ctrl_ping';
+ let payload = await this.#request(fn_name);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * Full-text error message for the last general error reported
+ * by the CQi server
+ *
+ * @returns {Promise}
+ */
+ async ctrl_last_general_error() {
+ const fn_name = 'ctrl_last_general_error';
+ return await this.#request(fn_name);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ask_feature_cqi_1_0() {
+ const fn_name = 'ask_feature_cqi_1_0';
+ return await this.#request(fn_name);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ask_feature_cl_2_3() {
+ const fn_name = 'ask_feature_cl_2_3';
+ return await this.#request(fn_name);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ask_feature_cqp_2_3() {
+ const fn_name = 'ask_feature_cqp_2_3';
+ return await this.#request(fn_name);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async corpus_list_corpora() {
+ const fn_name = 'corpus_list_corpora';
+ return await this.#request(fn_name);
+ }
+
+ /**
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_charset(corpus) {
+ const fn_name = 'corpus_charset';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_properties(corpus) {
+ const fn_name = 'corpus_properties';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_positional_attributes(corpus) {
+ const fn_name = 'corpus_positional_attributes';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_structural_attributes(corpus) {
+ const fn_name = 'corpus_structural_attributes';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} corpus
+ * @param {string} attribute
+ * @returns {Promise}
+ */
+ async corpus_structural_attribute_has_values(corpus, attribute) {
+ const fn_name = 'corpus_structural_attribute_has_values';
+ const fn_args = {corpus: corpus, attribute: attribute};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_alignment_attributes(corpus) {
+ const fn_name = 'corpus_alignment_attributes';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * the full name of as specified in its registry entry
+ *
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_full_name(corpus) {
+ const fn_name = 'corpus_full_name';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns the contents of the .info file of as a list of lines
+ *
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_info(corpus) {
+ const fn_name = 'corpus_info';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * try to unload a corpus and all its attributes from memory
+ *
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async corpus_drop_corpus(corpus) {
+ const fn_name = 'corpus_drop_corpus';
+ const fn_args = {corpus: corpus};
+ let payload = await this.#request(fn_name, fn_args);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * returns the size of :
+ * - number of tokens (positional)
+ * - number of regions (structural)
+ * - number of alignments (alignment)
+ *
+ * @param {string} attribute
+ * @returns {Promise}
+ */
+ async cl_attribute_size(attribute) {
+ const fn_name = 'cl_attribute_size';
+ const fn_args = {attribute: attribute};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns the number of entries in the lexicon of a positional attribute;
+ *
+ * valid lexicon IDs range from 0 .. (lexicon_size - 1)
+ *
+ * @param {string} attribute
+ * @returns {Promise}
+ */
+ async cl_lexicon_size(attribute) {
+ const fn_name = 'cl_lexicon_size';
+ const fn_args = {attribute: attribute};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * unload attribute from memory
+ *
+ * @param {string} attribute
+ * @returns {Promise}
+ */
+ async cl_drop_attribute(attribute) {
+ const fn_name = 'cl_drop_attribute';
+ const fn_args = {attribute: attribute};
+ let payload = await this.#request(fn_name, fn_args);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * NOTE: simple (scalar) mappings are applied to lists (the returned list
+ * has exactly the same length as the list passed as an argument)
+ */
+
+ /**
+ * returns -1 for every string in that is not found in the lexicon
+ *
+ * @param {string} attribute
+ * @param {strings[]} string
+ * @returns {Promise}
+ */
+ async cl_str2id(attribute, strings) {
+ const fn_name = 'cl_str2id';
+ const fn_args = {attribute: attribute, strings: strings};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns "" for every ID in that is out of range
+ *
+ * @param {string} attribute
+ * @param {number[]} id
+ * @returns {Promise}
+ */
+ async cl_id2str(attribute, id) {
+ const fn_name = 'cl_id2str';
+ const fn_args = {attribute: attribute, id: id};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns 0 for every ID in that is out of range
+ *
+ * @param {string} attribute
+ * @param {number[]} id
+ * @returns {Promise}
+ */
+ async cl_id2freq(attribute, id) {
+ const fn_name = 'cl_id2freq';
+ const fn_args = {attribute: attribute, id: id};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns -1 for every corpus position in that is out of range
+ *
+ * @param {string} attribute
+ * @param {number[]} cpos
+ * @returns {Promise}
+ */
+ async cl_cpos2id(attribute, cpos) {
+ const fn_name = 'cl_cpos2id';
+ const fn_args = {attribute: attribute, cpos: cpos};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns "" for every corpus position in that is out of range
+ *
+ * @param {string} attribute
+ * @param {number[]} cpos
+ * @returns {Promise}
+ */
+ async cl_cpos2str(attribute, cpos) {
+ const fn_name = 'cl_cpos2str';
+ const fn_args = {attribute: attribute, cpos: cpos};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns -1 for every corpus position not inside a structure region
+ *
+ * @param {string} attribute
+ * @param {number[]} cpos
+ * @returns {Promise}
+ */
+ async cl_cpos2struc(attribute, cpos) {
+ const fn_name = 'cl_cpos2struc';
+ const fn_args = {attribute: attribute, cpos: cpos};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * NOTE: temporary addition for the Euralex2000 tutorial, but should
+ * probably be included in CQi specs
+ */
+
+ /**
+ * returns left boundary of s-attribute region enclosing cpos,
+ * -1 if not in region
+ *
+ * @param {string} attribute
+ * @param {number[]} cpos
+ * @returns {Promise}
+ */
+ async cl_cpos2lbound(attribute, cpos) {
+ const fn_name = 'cl_cpos2lbound';
+ const fn_args = {attribute: attribute, cpos: cpos};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns right boundary of s-attribute region enclosing cpos,
+ * -1 if not in region
+ *
+ * @param {string} attribute
+ * @param {number[]} cpos
+ * @returns {Promise}
+ */
+ async cl_cpos2rbound(attribute, cpos) {
+ const fn_name = 'cl_cpos2rbound';
+ const fn_args = {attribute: attribute, cpos: cpos};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns -1 for every corpus position not inside an alignment
+ *
+ * @param {string} attribute
+ * @param {number[]} cpos
+ * @returns {Promise}
+ */
+ async cl_cpos2alg(attribute, cpos) {
+ const fn_name = 'cl_cpos2alg';
+ const fn_args = {attribute: attribute, cpos: cpos};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns annotated string values of structure regions in ;
+ * "" if out of range
+ *
+ * check corpus_structural_attribute_has_values() first
+ *
+ * @param {string} attribute
+ * @param {number[]} strucs
+ * @returns {Promise}
+ */
+ async cl_struc2str(attribute, strucs) {
+ const fn_name = 'cl_struc2str';
+ const fn_args = {attribute: attribute, strucs: strucs};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * NOTE: the following mappings take a single argument and return multiple
+ * values, including lists of arbitrary size
+ */
+
+ /**
+ * returns all corpus positions where the given token occurs
+ *
+ * @param {string} attribute
+ * @param {number} id
+ * @returns {Promise}
+ */
+ async cl_id2cpos(attribute, id) {
+ const fn_name = 'cl_id2cpos';
+ const fn_args = {attribute: attribute, id: id};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns all corpus positions where one of the tokens in occurs;
+ * the returned list is sorted as a whole, not per token id
+ *
+ * @param {string} attribute
+ * @param {number[]} id_list
+ * @returns {Promise}
+ */
+ async cl_idlist2cpos(attribute, id_list) {
+ const fn_name = 'cl_idlist2cpos';
+ const fn_args = {attribute: attribute, id_list: id_list};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns lexicon IDs of all tokens that match ;
+ * the returned list may be empty (size 0);
+ *
+ * @param {string} attribute
+ * @param {string} regex
+ * @returns {Promise}
+ */
+ async cl_regex2id(attribute, regex) {
+ const fn_name = 'cl_regex2id';
+ const fn_args = {attribute: attribute, regex: regex};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns start and end corpus positions of structure region
+ *
+ * @param {string} attribute
+ * @param {number} struc
+ * @returns {Promise<[number, number]>}
+ */
+ async cl_struc2cpos(attribute, struc) {
+ const fn_name = 'cl_struc2cpos';
+ const fn_args = {attribute: attribute, struc: struc};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * returns (src_start, src_end, target_start, target_end)
+ *
+ * @param {string} attribute
+ * @param {number} alg
+ * @returns {Promise<[number, number, number, number]>}
+ */
+ async alg2cpos(attribute, alg) {
+ const fn_name = 'alg2cpos';
+ const fn_args = {attribute: attribute, alg: alg};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * must include the ';' character terminating the query.
+ *
+ * @param {string} mother_corpus
+ * @param {string} subcorpus_name
+ * @param {string} query
+ * @returns {Promise}
+ */
+ async cqp_query(mother_corpus, subcorpus_name, query) {
+ const fn_name = 'cqp_query';
+ const fn_args = {mother_corpus: mother_corpus, subcorpus_name: subcorpus_name, query: query};
+ let payload = await this.#request(fn_name, fn_args);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * @param {string} corpus
+ * @returns {Promise}
+ */
+ async cqp_list_subcorpora(corpus) {
+ const fn_name = 'cqp_list_subcorpora';
+ const fn_args = {corpus: corpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} subcorpus
+ * @returns {Promise}
+ */
+ async cqp_subcorpus_size(subcorpus) {
+ const fn_name = 'cqp_subcorpus_size';
+ const fn_args = {subcorpus: subcorpus};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * @param {string} subcorpus
+ * @param {number} field
+ * @returns {Promise}
+ */
+ async cqp_subcorpus_has_field(subcorpus, field) {
+ const fn_name = 'cqp_subcorpus_has_field';
+ const fn_args = {subcorpus: subcorpus, field: field};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * Dump the values of for match ranges ..
+ * in . is one of the CQI_CONST_FIELD_* constants.
+ *
+ * @param {string} subcorpus
+ * @param {number} field
+ * @param {number} first
+ * @param {number} last
+ * @returns {Promise}
+ */
+ async cqp_dump_subcorpus(subcorpus, field, first, last) {
+ const fn_name = 'cqp_dump_subcorpus';
+ const fn_args = {subcorpus: subcorpus, field: field, first: first, last: last};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * delete a subcorpus from memory
+ *
+ * @param {string} subcorpus
+ * @returns {Promise}
+ */
+ async cqp_drop_subcorpus(subcorpus) {
+ const fn_name = 'cqp_drop_subcorpus';
+ const fn_args = {subcorpus: subcorpus};
+ let payload = await this.#request(fn_name, fn_args);
+ return new cqi.status.lookup[payload.code]();
+ }
+
+ /**
+ * NOTE: The following two functions are temporarily included for the
+ * Euralex 2000 tutorial demo
+ */
+
+ /**
+ * frequency distribution of single tokens
+ *
+ * returns (id, frequency) pairs flattened into a list of size 2*
+ * field is one of
+ * - CQI_CONST_FIELD_MATCH
+ * - CQI_CONST_FIELD_TARGET
+ * - CQI_CONST_FIELD_KEYWORD
+ *
+ * NB: pairs are sorted by frequency desc.
+ *
+ * @param {string} subcorpus
+ * @param {number} cutoff
+ * @param {number} field
+ * @param {string} attribute
+ * @returns {Promise}
+ */
+ async cqp_fdist_1(subcorpus, cutoff, field, attribute) {
+ const fn_name = 'cqp_fdist_1';
+ const fn_args = {subcorpus: subcorpus, cutoff: cutoff, field: field, attribute: attribute};
+ return await this.#request(fn_name, fn_args);
+ }
+
+ /**
+ * frequency distribution of pairs of tokens
+ *
+ * returns (id1, id2, frequency) pairs flattened into a list of
+ * size 3*
+ *
+ * NB: triples are sorted by frequency desc.
+ *
+ * @param {string} subcorpus
+ * @param {number} cutoff
+ * @param {number} field1
+ * @param {string} attribute1
+ * @param {number} field2
+ * @param {string} attribute2
+ * @returns {Promise}
+ */
+ async cqp_fdist_2(subcorpus, cutoff, field1, attribute1, field2, attribute2) {
+ const fn_name = 'cqp_fdist_2';
+ const fn_args = {subcorpus: subcorpus, cutoff: cutoff, field1: field1, attribute1: attribute1, field2: field2, attribute2: attribute2};
+ return await this.#request(fn_name, fn_args);
+ }
+};
diff --git a/app/static/js/cqi/api/package.js b/app/static/js/cqi/api/package.js
new file mode 100644
index 00000000..fb42389b
--- /dev/null
+++ b/app/static/js/cqi/api/package.js
@@ -0,0 +1 @@
+cqi.api = {};
diff --git a/app/static/js/cqi/client.js b/app/static/js/cqi/client.js
new file mode 100644
index 00000000..37a9c2b6
--- /dev/null
+++ b/app/static/js/cqi/client.js
@@ -0,0 +1,57 @@
+cqi.CQiClient = class CQiClient {
+ /**
+ * @param {string} host
+ * @param {string} corpusId
+ * @param {string} [version=0.1] version
+ */
+ constructor(host, corpusId, version = '0.1') {
+ /** @type {cqi.api.APIClient} */
+ this.api = new cqi.api.APIClient(host, corpusId, version);
+ }
+
+ /**
+ * @returns {cqi.models.corpora.CorpusCollection}
+ */
+ get corpora() {
+ return new cqi.models.corpora.CorpusCollection(this);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async bye() {
+ return await this.api.ctrl_bye();
+ }
+
+ /**
+ * @param {string} username
+ * @param {string} password
+ * @returns {Promise}
+ */
+ async connect(username, password) {
+ return await this.api.ctrl_connect(username, password);
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async ping() {
+ return await this.api.ctrl_ping();
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async userAbort() {
+ return await this.api.ctrl_user_abort();
+ }
+
+ /**
+ * Alias for "bye" method
+ *
+ * @returns {Promise}
+ */
+ async disconnect() {
+ return await this.api.ctrl_bye();
+ }
+};
diff --git a/app/static/js/cqi/errors.js b/app/static/js/cqi/errors.js
new file mode 100644
index 00000000..c7011eb7
--- /dev/null
+++ b/app/static/js/cqi/errors.js
@@ -0,0 +1,185 @@
+cqi.errors = {};
+
+
+/**
+ * A base class from which all other errors inherit.
+ * If you want to catch all errors that the CQi package might throw,
+ * catch this base error.
+ */
+cqi.errors.CQiError = class CQiError extends Error {
+ constructor(message) {
+ super(message);
+ this.code = undefined;
+ this.description = undefined;
+ }
+};
+
+
+cqi.errors.Error = class Error extends cqi.errors.CQiError {
+ constructor(message) {
+ super(message);
+ this.code = 2;
+ }
+};
+
+
+cqi.errors.ErrorGeneralError = class ErrorGeneralError extends cqi.errors.Error {
+ constructor(message) {
+ super(message);
+ this.code = 513;
+ }
+};
+
+
+cqi.errors.ErrorConnectRefused = class ErrorConnectRefused extends cqi.errors.Error {
+ constructor(message) {
+ super(message);
+ this.code = 514;
+ }
+};
+
+
+cqi.errors.ErrorUserAbort = class ErrorUserAbort extends cqi.errors.Error {
+ constructor(message) {
+ super(message);
+ this.code = 515;
+ }
+};
+
+
+cqi.errors.ErrorSyntaxError = class ErrorSyntaxError extends cqi.errors.Error {
+ constructor(message) {
+ super(message);
+ this.code = 516;
+ }
+};
+
+
+cqi.errors.CLError = class Error extends cqi.errors.CQiError {
+ constructor(message) {
+ super(message);
+ this.code = 4;
+ }
+};
+
+
+cqi.errors.CLErrorNoSuchAttribute = class CLErrorNoSuchAttribute extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1025;
+ this.description = "CQi server couldn't open attribute";
+ }
+};
+
+
+cqi.errors.CLErrorWrongAttributeType = class CLErrorWrongAttributeType extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1026;
+ }
+};
+
+
+cqi.errors.CLErrorOutOfRange = class CLErrorOutOfRange extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1027;
+ }
+};
+
+
+cqi.errors.CLErrorRegex = class CLErrorRegex extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1028;
+ }
+};
+
+
+cqi.errors.CLErrorCorpusAccess = class CLErrorCorpusAccess extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1029;
+ }
+};
+
+
+cqi.errors.CLErrorOutOfMemory = class CLErrorOutOfMemory extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1030;
+ this.description = 'CQi server has run out of memory; try discarding some other corpora and/or subcorpora';
+ }
+};
+
+
+cqi.errors.CLErrorInternal = class CLErrorInternal extends cqi.errors.CLError {
+ constructor(message) {
+ super(message);
+ this.code = 1031;
+ this.description = "The classical 'please contact technical support' error";
+ }
+};
+
+
+cqi.errors.CQPError = class Error extends cqi.errors.CQiError {
+ constructor(message) {
+ super(message);
+ this.code = 5;
+ }
+};
+
+
+cqi.errors.CQPErrorGeneral = class CQPErrorGeneral extends cqi.errors.CQPError {
+ constructor(message) {
+ super(message);
+ this.code = 1281;
+ }
+};
+
+
+cqi.errors.CQPErrorNoSuchCorpus = class CQPErrorNoSuchCorpus extends cqi.errors.CQPError {
+ constructor(message) {
+ super(message);
+ this.code = 1282;
+ }
+};
+
+
+cqi.errors.CQPErrorInvalidField = class CQPErrorInvalidField extends cqi.errors.CQPError {
+ constructor(message) {
+ super(message);
+ this.code = 1283;
+ }
+};
+
+
+cqi.errors.CQPErrorOutOfRange = class CQPErrorOutOfRange extends cqi.errors.CQPError {
+ constructor(message) {
+ super(message);
+ this.code = 1284;
+ this.description = 'A number is out of range';
+ }
+};
+
+
+cqi.errors.lookup = {
+ 2: cqi.errors.Error,
+ 513: cqi.errors.ErrorGeneralError,
+ 514: cqi.errors.ErrorConnectRefused,
+ 515: cqi.errors.ErrorUserAbort,
+ 516: cqi.errors.ErrorSyntaxError,
+ 4: cqi.errors.CLError,
+ 1025: cqi.errors.CLErrorNoSuchAttribute,
+ 1026: cqi.errors.CLErrorWrongAttributeType,
+ 1027: cqi.errors.CLErrorOutOfRange,
+ 1028: cqi.errors.CLErrorRegex,
+ 1029: cqi.errors.CLErrorCorpusAccess,
+ 1030: cqi.errors.CLErrorOutOfMemory,
+ 1031: cqi.errors.CLErrorInternal,
+ 5: cqi.errors.CQPError,
+ 1281: cqi.errors.CQPErrorGeneral,
+ 1282: cqi.errors.CQPErrorNoSuchCorpus,
+ 1283: cqi.errors.CQPErrorInvalidField,
+ 1284: cqi.errors.CQPErrorOutOfRange
+};
diff --git a/app/static/js/cqi/models/attributes.js b/app/static/js/cqi/models/attributes.js
new file mode 100644
index 00000000..8a0b987c
--- /dev/null
+++ b/app/static/js/cqi/models/attributes.js
@@ -0,0 +1,289 @@
+cqi.models.attributes = {};
+
+
+cqi.models.attributes.Attribute = class Attribute extends cqi.models.resource.Model {
+ /**
+ * @returns {string}
+ */
+ get apiName() {
+ return this.attrs.api_name;
+ }
+
+ /**
+ * @returns {string}
+ */
+ get name() {
+ return this.attrs.name;
+ }
+
+ /**
+ * @returns {number}
+ */
+ get size() {
+ return this.attrs.size;
+ }
+
+ /**
+ * @returns {Promise}
+ */
+ async drop() {
+ return await this.client.api.cl_drop_attribute(this.apiName);
+ }
+};
+
+
+cqi.models.attributes.AttributeCollection = class AttributeCollection extends cqi.models.resource.Collection {
+ /** @type{typeof cqi.models.attributes.Attribute} */
+ static model = cqi.models.attributes.Attribute;
+
+ /**
+ * @param {cqi.CQiClient} client
+ * @param {cqi.models.corpora.Corpus} corpus
+ */
+ constructor(client, corpus) {
+ super(client);
+ /** @type {cqi.models.corpora.Corpus} */
+ this.corpus = corpus;
+ }
+
+ /**
+ * @param {string} attributeName
+ * @returns {Promise