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} + */ + async _get(attributeName) { + /** @type{string} */ + let apiName = `${this.corpus.apiName}.${attributeName}`; + return { + api_name: apiName, + name: attributeName, + size: await this.client.api.cl_attribute_size(apiName) + } + } + + /** + * @param {string} attributeName + * @returns {Promise} + */ + async get(attributeName) { + return this.prepareModel(await this._get(attributeName)); + } +}; + + +cqi.models.attributes.AlignmentAttribute = class AlignmentAttribute extends cqi.models.attributes.Attribute { + /** + * @param {number} id + * @returns {Promise<[number, number, number, number]>} + */ + async cposById(id) { + return await this.client.api.cl_alg2cpos(this.apiName, id); + } + + /** + * @param {number[]} cposList + * @returns {Promise} + */ + async idsByCpos(cposList) { + return await this.client.api.cl_cpos2alg(this.apiName, cposList); + } +}; + + +cqi.models.attributes.AlignmentAttributeCollection = class AlignmentAttributeCollection extends cqi.models.attributes.AttributeCollection { + /** @type{typeof cqi.models.attributes.AlignmentAttribute} */ + static model = cqi.models.attributes.AlignmentAttribute; + + /** + * @returns {Promise} + */ + async list() { + /** @type {string[]} */ + let alignmentAttributeNames = await this.client.api.corpus_alignment_attributes(this.corpus.apiName); + /** @type {cqi.models.attributes.AlignmentAttribute[]} */ + let alignmentAttributes = []; + for (let alignmentAttributeName of alignmentAttributeNames) { + alignmentAttributes.push(await this.get(alignmentAttributeName)); + } + return alignmentAttributes; + } +}; + + +cqi.models.attributes.PositionalAttribute = class PositionalAttribute extends cqi.models.attributes.Attribute { + /** + * @returns {number} + */ + get lexiconSize() { + return this.attrs.lexicon_size; + } + + /** + * @param {number} id + * @returns {Promise} + */ + async cposById(id) { + return await this.client.api.cl_id2cpos(this.apiName, id); + } + + /** + * @param {number[]} idList + * @returns {Promise} + */ + async cposByIds(idList) { + return await this.client.api.cl_idlist2cpos(this.apiName, idList); + } + + /** + * @param {number[]} idList + * @returns {Promise} + */ + async freqsByIds(idList) { + return await this.client.api.cl_id2freq(this.apiName, idList); + } + + /** + * @param {number[]} cposList + * @returns {Promise} + */ + async idsByCpos(cposList) { + return await this.client.api.cl_cpos2id(this.apiName, cposList); + } + + /** + * @param {string} regex + * @returns {Promise} + */ + async idsByRegex(regex) { + return await this.client.api.cl_regex2id(this.apiName, regex); + } + + /** + * @param {string[]} valueList + * @returns {Promise} + */ + async idsByValues(valueList) { + return await this.client.api.cl_str2id(this.apiName, valueList); + } + + /** + * @param {number[]} cposList + * @returns {Promise} + */ + async valuesByCpos(cposList) { + return await this.client.api.cl_cpos2str(this.apiName, cposList); + } + + /** + * @param {number[]} idList + * @returns {Promise} + */ + async valuesByIds(idList) { + return await this.client.api.cl_id2str(this.apiName, idList); + } +}; + + +cqi.models.attributes.PositionalAttributeCollection = class PositionalAttributeCollection extends cqi.models.attributes.AttributeCollection { + /** @type{typeof cqi.models.attributes.PositionalAttribute} */ + static model = cqi.models.attributes.PositionalAttribute; + + /** + * @param {string} positionalAttributeName + * @returns {Promise} + */ + async _get(positionalAttributeName) { + let positionalAttribute = await super._get(positionalAttributeName); + positionalAttribute.lexicon_size = await this.client.api.cl_lexicon_size(positionalAttribute.api_name); + return positionalAttribute; + } + + /** + * @returns {Promise} + */ + async list() { + let positionalAttributeNames = await this.client.api.corpus_positional_attributes(this.corpus.apiName); + let positionalAttributes = []; + for (let positionalAttributeName of positionalAttributeNames) { + positionalAttributes.push(await this.get(positionalAttributeName)); + } + return positionalAttributes; + } +}; + + +cqi.models.attributes.StructuralAttribute = class StructuralAttribute extends cqi.models.attributes.Attribute { + /** + * @returns {boolean} + */ + get hasValues() { + return this.attrs.has_values; + } + + /** + * @param {number} id + * @returns {Promise<[number, number]>} + */ + async cposById(id) { + return await this.client.api.cl_struc2cpos(this.apiName, id); + } + + /** + * @param {number[]} cposList + * @returns {Promise} + */ + async idsByCpos(cposList) { + return await this.client.api.cl_cpos2struc(this.apiName, cposList); + } + + /** + * @param {number[]} cposList + * @returns {Promise} + */ + async lboundByCpos(cposList) { + return await this.client.api.cl_cpos2lbound(this.apiName, cposList); + } + + /** + * @param {number[]} cposList + * @returns {Promise} + */ + async rboundByCpos(cposList) { + return await this.client.api.cl_cpos2rbound(this.apiName, cposList); + } + + /** + * @param {number[]} idList + * @returns {Promise} + */ + async valuesByIds(idList) { + return await this.client.api.cl_struc2str(this.apiName, idList); + } +}; + + +cqi.models.attributes.StructuralAttributeCollection = class StructuralAttributeCollection extends cqi.models.attributes.AttributeCollection { + /** @type{typeof cqi.models.attributes.StructuralAttribute} */ + static model = cqi.models.attributes.StructuralAttribute; + + /** + * @param {string} structuralAttributeName + * @returns {Promise} + */ + async _get(structuralAttributeName) { + let structuralAttribute = await super._get(structuralAttributeName); + structuralAttribute.has_values = await this.client.api.cl_has_values(structuralAttribute.api_name); + return structuralAttribute; + } + + /** + * @returns {Promise} + */ + async list() { + let structuralAttributeNames = await this.client.api.corpus_structural_attributes(this.corpus.apiName); + let structuralAttributes = []; + for (let structuralAttributeName of structuralAttributeNames) { + structuralAttributes.push(await this.get(structuralAttributeName)); + } + return structuralAttributes; + } +}; diff --git a/app/static/js/cqi/models/corpora.js b/app/static/js/cqi/models/corpora.js new file mode 100644 index 00000000..9af467d0 --- /dev/null +++ b/app/static/js/cqi/models/corpora.js @@ -0,0 +1,127 @@ +cqi.models.corpora = {}; + + +cqi.models.corpora.Corpus = class Corpus 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 {string} + */ + get charset() { + return this.attrs.charset; + } + + /** + * @returns {string[]} + */ + get properties() { + return this.attrs?.properties; + } + + /** + * @returns {cqi.models.attributes.AlignmentAttributeCollection} + */ + get alignment_attributes() { + return new cqi.models.attributes.AlignmentAttributeCollection(this.client, this); + } + + /** + * @returns {cqi.models.attributes.PositionalAttributeCollection} + */ + get positional_attributes() { + return new cqi.models.attributes.PositionalAttributeCollection(this.client, this); + } + + /** + * @returns {cqi.models.attributes.StructuralAttributeCollection} + */ + get structural_attributes() { + return new cqi.models.attributes.StructuralAttributeCollection(this.client, this); + } + + /** + * @returns {cqi.models.subcorpora.SubcorpusCollection} + */ + get subcorpora() { + return new cqi.models.subcorpora.SubcorpusCollection(this.client, this); + } + + /** + * @returns {Promise} + */ + async drop() { + return await this.client.api.corpus_drop_corpus(this.apiName); + } + + /** + * @param {string} subcorpusName + * @param {string} query + * @returns {Promise} + */ + async query(subcorpusName, query) { + return await this.client.api.cqp_query(this.apiName, subcorpusName, query); + } +}; + + +cqi.models.corpora.CorpusCollection = class CorpusCollection extends cqi.models.resource.Collection { + /** @type {typeof cqi.models.corpora.Corpus} */ + static model = cqi.models.corpora.Corpus; + + /** + * @param {string} corpusName + * @returns {Promise} + */ + async _get(corpusName) { + return { + api_name: corpusName, + charset: await this.client.api.corpus_charset(corpusName), + // full_name: await this.client.api.corpus_full_name(api_name), + // info: await this.client.api.corpus_info(api_name), + name: corpusName, + properties: await this.client.api.corpus_properties(corpusName), + size: await this.client.api.cl_attribute_size(`${corpusName}.word`) + } + } + + /** + * @param {string} corpusName + * @returns {Promise} + */ + async get(corpusName) { + return this.prepareModel(await this._get(corpusName)); + } + + /** + * @returns {Promise} + */ + async list() { + /** @type {string[]} */ + let corpusNames = await this.client.api.corpus_list_corpora(); + /** @type {cqi.models.corpora.Corpus[]} */ + let corpora = []; + for (let corpusName of corpusNames) { + corpora.push(await this.get(corpusName)); + } + return corpora; + } +}; diff --git a/app/static/js/cqi/models/package.js b/app/static/js/cqi/models/package.js new file mode 100644 index 00000000..4973862f --- /dev/null +++ b/app/static/js/cqi/models/package.js @@ -0,0 +1 @@ +cqi.models = {}; diff --git a/app/static/js/cqi/models/resource.js b/app/static/js/cqi/models/resource.js new file mode 100644 index 00000000..9d3afde3 --- /dev/null +++ b/app/static/js/cqi/models/resource.js @@ -0,0 +1,90 @@ +cqi.models.resource = {}; + + +/** + * A base class for representing a single object on the server. + */ +cqi.models.resource.Model = class Model { + /** + * @param {object} attrs + * @param {cqi.CQiClient} client + * @param {cqi.models.resource.Collection} collection + */ + constructor(attrs, client, collection) { + /** + * A client pointing at the server that this object is on. + * + * @type {cqi.CQiClient} + */ + this.client = client; + /** + * The collection that this model is part of. + * + * @type {cqi.models.resource.Collection} + */ + this.collection = collection; + /** + * The raw representation of this object from the API + * + * @type {object} + */ + this.attrs = attrs; + } + + /** + * @returns {string} + */ + get apiName() { + throw new Error('Not implemented'); + } + + /** + * @returns {Promise} + */ + async reload() { + this.attrs = await this.collection.get(this.apiName).attrs; + } +}; + + +/** + * A base class for representing all objects of a particular type on the server. + */ +cqi.models.resource.Collection = class Collection { + /** + * The type of object this collection represents, set by subclasses + * + * @type {typeof cqi.models.resource.Model} + */ + static model; + + /** + * @param {cqi.CQiClient} client + */ + constructor(client) { + /** + * A client pointing at the server that this object is on. + * + * @type {cqi.CQiClient} + */ + this.client = client; + } + + async list() { + throw new Error('Not implemented'); + } + + async get() { + throw new Error('Not implemented'); + } + + /** + * Create a model from a set of attributes. + * + * @param {object} attrs + * @returns {cqi.models.resource.Model} + */ + prepareModel(attrs) { + return new this.constructor.model(attrs, this.client, this); + } +}; diff --git a/app/static/js/cqi/models/subcorpora.js b/app/static/js/cqi/models/subcorpora.js new file mode 100644 index 00000000..0890fc76 --- /dev/null +++ b/app/static/js/cqi/models/subcorpora.js @@ -0,0 +1,155 @@ +cqi.models.subcorpora = {}; + + +cqi.models.subcorpora.Subcorpus = class Subcorpus extends cqi.models.resource.Model { + /** + * @returns {string} + */ + get apiName() { + return this.attrs.api_name; + } + + /** + * @returns {object} + */ + get fields() { + return this.attrs.fields; + } + + /** + * @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.cqp_drop_subcorpus(this.apiName); + } + + /** + * @param {number} field + * @param {number} first + * @param {number} last + * @returns {Promise} + */ + async dump(field, first, last) { + return await this.client.api.cqp_dump_subcorpus( + this.apiName, + field, + first, + last + ); + } + + /** + * @param {number} cutoff + * @param {number} field + * @param {cqi.models.attributes.PositionalAttribute} attribute + * @returns {Promise} + */ + async fdist1(cutoff, field, attribute) { + return await this.client.api.cqp_fdist_1( + this.apiName, + cutoff, + field, + attribute.apiName + ); + } + + /** + * @param {number} cutoff + * @param {number} field1 + * @param {cqi.models.attributes.PositionalAttribute} attribute1 + * @param {number} field2 + * @param {cqi.models.attributes.PositionalAttribute} attribute2 + * @returns {Promise} + */ + async fdist2(cutoff, field1, attribute1, field2, attribute2) { + return await this.client.api.cqp_fdist_2( + this.apiName, + cutoff, + field1, + attribute1.apiName, + field2, + attribute2.apiName + ); + } +}; + + +cqi.models.subcorpora.SubcorpusCollection = class SubcorpusCollection extends cqi.models.resource.Collection { + /** @type {typeof cqi.models.subcorpora.Subcorpus} */ + static model = cqi.models.subcorpora.Subcorpus; + + /** + * @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} subcorpusName + * @returns {Promise} + */ + async _get(subcorpusName) { + /** @type {string} */ + let apiName = `${this.corpus.apiName}:${subcorpusName}`; + /** @type {object} */ + let fields = {}; + if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_MATCH)) { + fields.match = cqi.CONST_FIELD_MATCH; + } + if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_MATCHEND)) { + fields.matchend = cqi.CONST_FIELD_MATCHEND + } + if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_TARGET)) { + fields.target = cqi.CONST_FIELD_TARGET + } + if (await this.client.api.cqp_subcorpus_has_field(apiName, cqi.CONST_FIELD_KEYWORD)) { + fields.keyword = cqi.CONST_FIELD_KEYWORD + } + return { + api_name: apiName, + fields: fields, + name: subcorpusName, + size: await this.client.api.cqp_subcorpus_size(apiName) + } + } + + /** + * @param {string} subcorpusName + * @returns {Promise} + */ + async get(subcorpusName) { + return this.prepareModel(await this._get(subcorpusName)); + } + + /** + * @returns {Promise} + */ + async list() { + /** @type {string[]} */ + let subcorpusNames = await this.client.api.cqp_list_subcorpora(this.corpus.apiName); + /** @type {cqi.models.subcorpora.Subcorpus[]} */ + let subcorpora = []; + for (let subcorpusName of subcorpusNames) { + subcorpora.push(await this.get(subcorpusName)); + } + return subcorpora; + } +}; diff --git a/app/static/js/cqi/package.js b/app/static/js/cqi/package.js new file mode 100644 index 00000000..1558b308 --- /dev/null +++ b/app/static/js/cqi/package.js @@ -0,0 +1,6 @@ +var cqi = {}; + +cqi.CONST_FIELD_KEYWORD = 9; +cqi.CONST_FIELD_MATCH = 16; +cqi.CONST_FIELD_MATCHEND = 17; +cqi.CONST_FIELD_TARGET = 0; diff --git a/app/static/js/cqi/status.js b/app/static/js/cqi/status.js new file mode 100644 index 00000000..0782ee26 --- /dev/null +++ b/app/static/js/cqi/status.js @@ -0,0 +1,51 @@ +cqi.status = {}; + + +/** + * A base class from which all other status inherit. + */ +cqi.status.CQiStatus = class CQiStatus { + constructor() { + this.code = undefined; + } +}; + + +cqi.status.StatusOk = class StatusOk extends cqi.status.CQiStatus { + constructor() { + super(); + this.code = 257; + } +}; + + +cqi.status.StatusConnectOk = class StatusConnectOk extends cqi.status.CQiStatus { + constructor() { + super(); + this.code = 258; + } +}; + + +cqi.status.StatusByeOk = class StatusByeOk extends cqi.status.CQiStatus { + constructor() { + super(); + this.code = 259; + } +}; + + +cqi.status.StatusPingOk = class StatusPingOk extends cqi.status.CQiStatus { + constructor() { + super(); + this.code = 260; + } +}; + + +cqi.status.lookup = { + 257: cqi.status.StatusOk, + 258: cqi.status.StatusConnectOk, + 259: cqi.status.StatusByeOk, + 260: cqi.status.StatusPingOk +}; diff --git a/app/templates/_scripts.html.j2 b/app/templates/_scripts.html.j2 index 78ebd9f7..04728ce0 100644 --- a/app/templates/_scripts.html.j2 +++ b/app/templates/_scripts.html.j2 @@ -3,6 +3,23 @@ +{%- assets + filters='rjsmin', + output='gen/cqi.%(version)s.js', + 'js/cqi/package.js', + 'js/cqi/errors.js', + 'js/cqi/status.js', + 'js/cqi/api/package.js', + 'js/cqi/api/client.js', + 'js/cqi/models/package.js', + 'js/cqi/models/resource.js', + 'js/cqi/models/attributes.js', + 'js/cqi/models/subcorpora.js', + 'js/cqi/models/corpora.js', + 'js/cqi/client.js' +%} + +{%- endassets %} {%- assets filters='rjsmin', output='gen/app.%(version)s.js',