Change directory structure (move ./nopaque/* to ./)

This commit is contained in:
Patrick Jentsch
2021-07-20 15:07:42 +02:00
parent ff39d8d650
commit d6ab379418
231 changed files with 26 additions and 23 deletions

3894
app/static/js/darkreader.js Normal file

File diff suppressed because it is too large Load Diff

36
app/static/js/jsonpatch.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
app/static/js/list.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version":3,"file":"list.min.js","sources":["webpack://List/list.min.js"],"mappings":"AAAA","sourceRoot":""}

6
app/static/js/materialize.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,282 @@
/**
* This class is used to create a Client object.
* The client handels the client server communication.
* It communicates with the server (e.g. connection or query)
* and recieves data from it, if dynamicMode is true.
* If dynamicMode is false, the client can also handle data that is already
* loaded and is not coming in, in chunks.
*/
class Client {
constructor({corpusId = null,
socket = null,
logging = true,
dynamicMode = true,
fullContext = null} = {}) {
this.corpusId = corpusId;
this.dynamicMode = dynamicMode;
this.logging = logging;
this.requestQueryProgress = 0;
this.socket = socket;
this.eventListeners = {};
this.isBusy = false;
this.fullContext = fullContext;
/**
* Disables all console logging.
* This is global. So every other log message in every other Class or
* function used in conjunction with the client either logs or does not
* log depending on the logging flag. It is kind of hacky but not bad.
* Credits to https://gist.github.com/kmonsoor/0244fdb4ad79a4826371e58a1a5fa984
*/
if (!logging) {
(() => {
let console = (window.console = window.console || {});
[
'assert', 'clear', 'count', 'debug', 'dir', 'dirxml',
'error', 'exception', 'group', 'groupCollapsed', 'groupEnd',
'info', 'log', 'markTimeline', 'profile', 'profileEnd', 'table',
'time', 'timeEnd', 'timeStamp', 'trace', 'warn'
].forEach((method) => {
console[method] = () => {};
});
})();
}
console.info("Client initialized:", this);
}
/**
* Registers one or more event listeners to the Client. Either socket or
* custom javascript events. Event listeners are class instances of
* ClientEventListener implemented further below.
*/
setSocketEventListeners(eventListeners) {
for (let eventListener of eventListeners) {
this.eventListeners[eventListener.type] = eventListener;
}
}
/**
* Loads the event listeners that have been registered with the function
* above so that they will be triggered on their assigned
* type strings. Type strings double as the socket event event names or
* javascript custom event names.
*/
loadSocketEventListeners() {
for (let [type, listener] of Object.entries(this.eventListeners)) {
listener.listenerFunction(type, this);
}
}
/**
* This functions emits the 'notify-view' custom javascript event. This
* triggers specific functions in the View depending on the caseIdentifier.
* The detail object can hold any type of data the View needs to know about
* to represent those to the user.
*/
notifyView(caseIdentifier, detailObject={}, notificationType='info',
raiseModalFeedback=true) {
detailObject.caseIdentifier = caseIdentifier;
detailObject.client = this;
detailObject.notificationType = notificationType;
detailObject.raiseModalFeedback = raiseModalFeedback;
const event = new CustomEvent('notify-view', { detail: detailObject });
console[notificationType]('Client dispatching Notification with details:',
detailObject);
document.dispatchEvent(event);
}
/**
* Connects to the corpus analysis session running on the server side for the
* specified corpus via socket.io.
*/
connect() {
console.info('corpus_analysis_init: Client connecting to session via',
'socket.emit');
this.socket.emit('corpus_analysis_init', this.corpusId);
}
// Gets the meta data of the current corpus.
getMetaData() {
this.isBusy = true;
console.info('corpus_analysis_meta_data: Client getting meta data via',
'socket.emit.');
this.socket.emit('corpus_analysis_meta_data', this.corpusId);
}
/**
* Emits query to the server via socket.io. Server will send the results
* back.
*/
query(queryStr) {
this.isBusy = true;
console.info('corpus_analysis_query: Client sending query via',
'socket.emit for the query', queryStr);
this.socket.emit('corpus_analysis_query', queryStr);
}
/**
* Requests results data either for, 'full-results', 'sub-results' or
* 'inspect-results' (resultsType).
* Gets full results for evere provided dataIndex (one match).
* Full results means with full context. So the Client has to request all
* matches from the server again!
**/
getResultsData(resultsType, dataIndexes, results) {
let tmp_first_cpos = [];
let tmp_last_cpos = [];
let objectKey = '';
if (resultsType === 'full-results') {
objectKey = 'fullResultsData';
} else if (resultsType === 'sub-results') {
objectKey = 'subResultsData';
} else if (resultsType === 'inspect-results') {
objectKey = 'inspectResultsData';
}
// Delete old data before new data is coming in.
results[objectKey].init();
for (let dataIndex of dataIndexes) {
tmp_first_cpos.push(results.data.matches[dataIndex].c[0]);
tmp_last_cpos.push(results.data.matches[dataIndex].c[1]);
}
this.socket.emit('corpus_analysis_get_match_with_full_context',
{type: resultsType,
data_indexes: dataIndexes,
first_cpos: tmp_first_cpos,
last_cpos: tmp_last_cpos,});
}
/**
* Gets results data either for, 'full-results' or 'sub-results'
* Gets results for every provided dataIndex (one match) without full
* context. Because no context is needed the results data is gathered locally
* from results.data and not from the server.
**/
getResultsDataWithoutContext(resultsType, dataIndexes, results, resultsList) {
this.notifyView('results-data-recieving', {fullContext: false});
let objectKey = '';
if (resultsType === 'full-results') {
console.info('Saving full-results data without full context.');
objectKey = 'fullResultsData';
} else if (resultsType === 'sub-results') {
console.info('Saving sub-results data without full context.');
objectKey = 'subResultsData';
}
// Get matches from results.data.
let matches = [];
let cpos = [];
let match;
for (let index of dataIndexes) {
match = results.data.matches[index]
matches.push(match)
// Get cpos from match.
let {lc, c, rc} = resultsList.helperCreateCpos(results.data.cpos_ranges,
match);
cpos.push(...lc);
cpos.push(...c);
cpos.push(...rc);
}
// Get cpos_lookups from cposes.
let cpos_lookup = {};
let textIds = new Set;
for (let single_cpos of cpos) {
textIds.add(results.data.cpos_lookup[single_cpos].text);
Object.assign(cpos_lookup, { [single_cpos]: results.data.cpos_lookup[single_cpos]});
}
let text = {};
let text_lookup = {};
for (let id of textIds) {
text[id] = results.data.text_lookup[id];
Object.assign(text_lookup, text);
}
/**
* Save the data from results.dat either in results.fullResultsData or
* results.subResultsData.
*/
results[objectKey].init();
results[objectKey].matches.push(...matches);
results[objectKey].addData(cpos_lookup, "cpos_lookup");
results[objectKey].addData(text_lookup, "text_lookup");
results[objectKey].addData(results.metaData);
results[objectKey].query = results.data.query;
results[objectKey].corpus_type = resultsType;
results[objectKey].match_count = matches.length;
results[objectKey].cpos_ranges = results.data.cpos_ranges;
results[objectKey].fullContext = false;
if (objectKey === 'subResultsData') {
// Remove match_count from texts, because they are useless in sub results
for (let [key, value] of Object.entries(results[objectKey].text_lookup)) {
delete results[objectKey].text_lookup[key].match_count;
}
}
console.info('Results data without context has been saved.', results);
this.isBusy = false;
this.notifyView('results-data-recieved', {type: resultsType,
results: results,
fullContext: false});
}
}
/**
* This class is used to create an event listener listening for socket or
* javascript custom events.
* Input are an identifying type string, the listener function and callbacks
* which will be executed as part of the listener function. The identifying
* type string is also used as the socket event or custom javascript event name
* identifier.
*/
class ClientEventListener {
constructor(type, listenerFunction) {
this.listenerCallbacks = {};
this.listenerFunction = listenerFunction;
this.type = type;
}
// Registers callbacks to this ClientEventListener.
setCallbacks(listenerCallbacks) {
for (let listenerCallback of listenerCallbacks) {
this.listenerCallbacks[listenerCallback.type] = listenerCallback;
}
}
/** Shorthand to execute all registered callbacks with same defaultArgs
* in insertion order.
* NOTE:
* Since ECMAScript 2015, objects do preserve creation order for
* string and Symbol keys. In JavaScript engines that comply with the
* ECMAScript 2015 spec, iterating over an object with only string keys will
* yield the keys in order of insertion.
* So all modern Browsers.
*/
executeCallbacks(defaultArgs) {
for (let [type, listenerCallback] of Object.entries(this.listenerCallbacks)) {
listenerCallback.callbackFunction(...defaultArgs,
...listenerCallback.args);
}
}
// Executes a specific registered callback by provoding a type string.
executeCallback(defaultArgs, type) {
let listenerCallback = this.listenerCallbacks[type];
let args = defaultArgs.concat(listenerCallback.args) ;
listenerCallback.callbackFunction(...args);
}
}
/**
* This class is used to create an ListenerCallback which will be registered
* to an ClientEventListener so the listener can invoke the associated
* callback functions.
*/
class ListenerCallback {
constructor(type, callbackFunction, argsList) {
this.args = argsList;
this.callbackFunction = callbackFunction;
this.type = type;
}
}
// Export Classes from this module.
export {
Client,
ClientEventListener,
ListenerCallback,
};

View File

@ -0,0 +1,161 @@
/**
* This callback is called on a socket.on "corpus_analysis_send_meta_data".
* Handels incoming corpus metadata
*/
function saveMetaData(...args) {
let [payload, client, results, rest] = args;
client.notifyView('meta-data-recieving');
results.metaData.init(payload)
console.info('Metada saved:', results);
client.isBusy = false;
client.notifyView('meta-data-recieved');
}
/**
* This callback should be registered to the SocketEventListener 'recieveQueryStatus'.
* It just gets the incoming status data of the issued query
* and does some preperation work like hiding or showing elements and deleting
* the data from the last query.
*/
function prepareQueryData(...args) {
// deletes old data from query issued before this new query
let [payload, client, results, rest] = args;
// always initialize the results to delete data from the query issued before
results.init();
results.data.match_count = payload.match_count;
client.requestQueryProgress = 0;
client.notifyView('query-data-prepareing', { results: results });
}
/**
* This callbacks saves the incoming query data chunks into the model results.
*/
function saveQueryData(...args) {
let [payload, client, results, rest] = args;
// Get data matches length before new chunk data is being inserted
let dataLength = results.data.matches.length;
if (client.dynamicMode) {
// Incorporating new chunk data into full results
results.data.matches.push(...payload.chunk.matches);
results.data.addData(payload.chunk.cpos_lookup, 'cpos_lookup');
results.data.addData(payload.chunk.text_lookup, 'text_lookup');
/**
* Increment match_counts per text in a global results varaible because
* they are coming in chunkwise.
*/
if (payload.chunk.text_lookup) {
for (let [text_key, value] of Object.entries(payload.chunk.text_lookup)) {
if (!(text_key in results.tmp_match_counts)) {
results.tmp_match_counts[text_key] = {match_count: 0};
}
results.tmp_match_counts[text_key].match_count += payload.chunk.text_lookup[text_key].match_count;
}
}
results.data.cpos_ranges = payload.chunk.cpos_ranges;
let queryFormElement = document.querySelector('#query-form');
results.data.getQueryStr(queryFormElement);
client.requestQueryProgress = payload.progress;
client.notifyView('query-data-recieving',
{ results: results,
client: client,
dataLength: dataLength });
console.info('Query data chunk saved', results.data);
if (client.requestQueryProgress === 100) {
client.isBusy = false;
// Update text_lookup with tmp_match_counts.
for (let [text_key, value] of Object.entries(results.tmp_match_counts)) {
results.data.text_lookup[text_key].match_count = results.tmp_match_counts[text_key].match_count;
}
client.notifyView('query-data-recieved');
}
} else {
results.data.matches.push(...payload.matches);
results.data.addData(payload.cpos_lookup, 'cpos_lookup');
results.data.addData(payload.text_lookup, 'text_lookup');
results.data.cpos_ranges = payload.cpos_ranges;
let queryFormElement = document.querySelector('#query-form');
results.data.getQueryStr(queryFormElement);
client.requestQueryProgress = 100;
client.notifyView('query-data-recieving',
{ results: results,
client: client,
dataLength: dataLength });
console.info('Query data chunk saved', results.data);
if (client.requestQueryProgress === 100) {
console.log(results.data);
client.notifyView('query-data-recieved');
}
}
}
/**
* This callback gets the results data for the export. Either requesting it
* whith full context from the server or gets it locally without full context
* from the already present results.data. Result data is identified with the
* dataIndexes. On index is one match.
*/
function getResultsData(...args) {
let [resultsType, dataIndexes, resultsList, client, results, rest] = args;
client.isBusy = true;
if (resultsList.exportFullInspectContext.checked
|| resultsType === 'inspect-results') {
console.info('Get results with full context');
client.getResultsData(resultsType, dataIndexes, results);
} else {
console.info('Get results without full context');
client.getResultsDataWithoutContext(resultsType, dataIndexes, results,
resultsList);
}
}
/**
* Handles incoming results which have been requested via getResultsData(). and
* saves the data accorindgly into the results object.
*/
function saveResultsData(...args) {
let [payload, type, client, results, rest] = args;
let objectKey = '';
if (type === 'full-results') {
console.info('Saving full-results data.');
objectKey = 'fullResultsData';
} else if (type === 'sub-results') {
console.info('Saving sub-results data.');
objectKey = 'subResultsData';
} else if (type = 'inspect-results') {
objectKey = 'inspectResultsData';
console.info('Saving inspect-results data');
}
// Save incoming data. Data is incoming one match at a time.
results[objectKey].matches.push(...payload.matches);
results[objectKey].addData(payload.cpos_lookup, 'cpos_lookup');
results[objectKey].addData(payload.text_lookup, 'text_lookup');
results[objectKey].addData(results.metaData);
results[objectKey].query = results.data.query;
results[objectKey].corpus_type = type;
results[objectKey].match_count += 1;
results[objectKey].cpos_ranges = payload.cpos_ranges;
results[objectKey].fullContext = true;
console.info('Results data has been saved.', results);
// Notify view to update progress bar
client.notifyView('results-data-recieving', {type: type,
progress: payload.progress})
if (payload.progress === 100) {
client.isBusy = false;
if (objectKey === 'fullResultsData') {
// Get match count per text from results.data only for fullResultsData
results[objectKey].text_lookup = results.data.text_lookup;
}
client.notifyView('results-data-recieved', {type: type,
results: results,
fullContext: true});
}
}
// export callbacks
export {
prepareQueryData,
saveMetaData,
saveQueryData,
getResultsData,
saveResultsData,
};

View File

@ -0,0 +1,201 @@
/**
* This file contains the listener functions which can be assigned to the
* coprus_analysis client. So that the incoming data/status informations will
* be handled. There are several listeners listening for socket .io events.
* Further below one javascript custom event listener is specified. This
* listener listens for javascript custom events which are being dispatched by
* the View (resultsList).
*/
// Listeners for socket io events
/**
* Recieves a corpus analysis connected signal via socket.io.
*/
function recieveConnected(type, client) {
client.socket.on(type, (response) => {
/**
* Check if request for session was OK.
* If OK execute registered callbacks and notify View.
*/
if (response.code === 200) {
console.group('Connected!')
console.info('corpus_analysis_init: Client recieving connected codes',
'codes via socket.on');
console.info(`corpus_analysis_init: ${response.code} - ${response.msg}`);
console.info('corpus_analysis_init: Initialization succeeded');
console.info(response);
client.notifyView('connected');
console.groupEnd();
// get meta data immediately
client.getMetaData();
} else {
let errorText = `Error ${response.code} - ${response.msg}`;
console.group('Connection failed!');
console.error(`corpus_analysis_init: ${errorText}`);
client.notifyView('client-failed', { msg: errorText }, 'error');
console.groupEnd();
}
});
}
/**
* Recieves meta data from the server via socket.io.
*/
function recieveMetaData(type, client) {
client.socket.on(type, (response) => {
/**
* Check if request for session was OK.
* If OK execute registered callbacks and notify View.
*/
if (response.code === 200) {
console.group('Client recieving meta data')
console.info('corpus_analysis_meta_data: Client recieving meta data',
'via socket.on');
console.info(`corpus_analysis_meta_data: ${response.code} - ${response.msg}`);
console.info(response);
// executing the registered callbacks
client.eventListeners[type].executeCallbacks([response.payload]);
console.groupEnd();
} else {
let errorText = `Error ${response.payload.code} - ${response.payload.msg}`;
console.group('Failed to recieve meta data.');
console.error('corpus_analysis_meta_data: Client failed to recieve',
'meta data via socket.on');
console.error(`corpus_analysis_meta_data: ${errorText}`);
client.notifyView('client-failed', { msg: errorText }, 'error');
console.groupEnd();
}
});
}
/**
* Recieves the query process status before any actual results are being
* transmitted. So it recieves error codes if a query failed or
* was invalid etc.
* Also prepares the result.jsList for the incoming data.
*/
function recieveQueryStatus(type, client) {
/**
* Check if request for session was OK.
* If OK execute registered callbacks and notify View.
*/
client.socket.on(type, (response) => {
if (response.code === 200) {
console.group('corpus_analysis_query: Client recieving query process',
'status via socket.on');
console.info(`corpus_analysis_query: ${response.code} - ${response.msg}`);
console.info(response);
// executing the registered callbacks
client.eventListeners[type].executeCallbacks([response.payload]);
console.groupEnd();
} else {
let errorText = `Error ${response.payload.code} - ${response.payload.msg}`;
console.group('corpus_analysis_query: Client failed recieving',
'query process status via socket.on');
if (response.payload.code == 1281) {
errorText += ' - Invalid Query';
console.error(`corpus_analysis_query: ${errorText}`);
client.notifyView('client-failed', { msg: errorText }, 'error', false);
} else {
console.error(`corpus_analysis_query: ${errorText}`);
client.notifyView('client-failed', { msg: errorText }, 'error');
}
console.groupEnd();
}
});
}
/**
* Recieves the query data from the request and handles it.
*/
function recieveQueryData(type, client) {
/**
* Check if request for session was OK.
* If OK execute registered callbacks and notify View.
*/
client.socket.on(type, (response) => {
if (response.code === 200) {
console.group('corpus_analysis_query_results: Recieveing query data')
console.info('Client recieving query data via socket.on');
console.info('Recieved chunk', response);
/**
* Execute registered callbacks and notify View.
*/
client.eventListeners[type].executeCallbacks([response.payload]);
console.info('Added chunk data to results.data.');
console.groupEnd();
} else {
let errorText = `Error ${response.payload.code} - ${response.payload.msg}`;
console.group('corpus_analysis_query_results: Client failed recieving',
'the results via socket.on');
console.error(`corpus_analysis_query: ${errorText}`);
client.notifyView('client-failed', { msg: errorText }, 'error');
console.groupEnd();
}
});
}
/**
* Recieves the data requested by the create Results or sub results button
*/
function recieveResultsData(type, client) {
client.socket.on(type, (response) => {
/**
* Check if request for session was OK.
* If OK execute registered callbacks and notify View.
*/
if (response.code === 200) {
console.group('Client recieving results data')
console.info('corpus_analysis_get_match_with_full_context: Client recieving results data',
'via socket.on');
console.info(`corpus_analysis_get_match_with_full_context: ${response.code} - ${response.msg}`);
console.info(response);
// executing the registered callbacks
client.eventListeners[type].executeCallbacks([response.payload,
response.type]);
console.groupEnd();
} else {
let errorText = `Error ${response.payload.code} - ${response.payload.msg}`;
console.group('Failed to recieve results data.');
console.error('corpus_analysis_get_match_with_full_context: Client failed to recieve',
'results data via socket.on');
console.error(`corpus_analysis_get_match_with_full_context: ${errorText}`);
client.notifyView('client-failed', { msg: errorText }, 'error');
console.groupEnd();
}
});
}
/*
* This is the javascript custom event listener, listening for events
* dispatched by the View.
*/
function recieveViewNotification(type, client) {
document.addEventListener(type, (event) => {
let caseIdentifier = event.detail.caseIdentifier;
switch(caseIdentifier) {
case 'get-results':
console.info('Client getting full results for export.');
// execute callback or functions
client.eventListeners[type].executeCallback([event.detail.resultsType,
event.detail.dataIndexes,
event.detail.resultsList],
caseIdentifier);
break
default:
console.error('Recieved unkown notification case identifier from View');
// do something to not crash the analysis session?
}
});
}
// export listeners from this module
export {
recieveConnected,
recieveMetaData,
recieveQueryStatus,
recieveQueryData,
recieveViewNotification,
recieveResultsData,
};

View File

@ -0,0 +1,141 @@
/**
* These classes are implementing the data store of the corpus_analysis
* package. If we follow the idea of the Model View Controller Pattern these
* classes combined in the Results class define the Model.
*/
// Results class bundleing the different data objects.
class Results {
constructor() {
this.data = new Data();
this.metaData = new MetaData();
this.fullResultsData = new Data();
this.subResultsData = new Data();
this.inspectResultsData = new Data();
console.info('Initialized the Results object.');
}
// Reset all the data objects in the results class and thus emptying them.
init() {
this.data.init();
this.metaData.init();
this.fullResultsData.init();
this.subResultsData.init();
this.inspectResultsData.init();
// Temporarly save match counts per text
this.tmp_match_counts = {};
}
}
/**
* Class that defines the actual data objects holding the results data
* requested by the client. Data can be the results of a query, full results
* data for the export, sub results data for the export or inspect results data.
* All kinds are structured the same way.
*/
class Data {
/**
* Sets empty object structure. Also usefull to delete old results.
* MatchCount default is 0.
*/
init(matchCount=0, type="results") {
// List of all c with lc and rc CPOS.
this.matches = [];
/**
* CPOS lookup object. CPOS are the key and value are infos about the CPOS
* like lemma, ner, pos, text ID etc. CPOS from the matches correspond to
* exactly one object in the cpos_lookup.
*/
this.cpos_lookup = {};
/**
* Same like above but for text IDs. One CPOS object always has a text ID
* referencing on text object in the text_lookup. Text ID is the key. Values
* are author, publishing year etc.
*/
this.text_lookup = {};
this.match_count = matchCount;
this.corpus_type = 'results';
this.cpos_ranges = null;
this.query = '';
}
/**
* Function to add json data/object data to this data instance.
* If no key is specified the entire data will be assigned to this data
* instance.
*/
addData(jsonData, key=null) {
if (key !== null) {
Object.assign(this[key], jsonData);
} else if (key === null) {
Object.assign(this, jsonData)
}
}
// Get query as a string from the form Element.
getQueryStr(queryFormElement) {
// gets query
let queryFormData;
let queryStr;
queryFormData = new FormData(queryFormElement);
queryStr = queryFormData.get('query-form-query');
this['query'] = queryStr;
}
// Function creates a unique and safe filename for the download.
createDownloadFilename(suffix) {
let today = new Date();
let currentDate = `${today.getUTCFullYear()}` +
`-${(today.getUTCMonth() + 1)}` +
`-${today.getUTCDate()}`;
let currentTime = `${today.getUTCHours()}h` +
`${today.getUTCMinutes()}m` +
`${today.getUTCSeconds()}s`;
let safeFilename = this.query.replace(/[^a-z0-9_-]/gi, "_");
let resultFilename = `UTC-${currentDate}_${currentTime}_${safeFilename}_${suffix}`;
return resultFilename
}
/**
* Function to download data as Blob created from string.
* Should be private but that is not yet a feature of javascript 08.04.2020
*/
download(downloadElement, dataStr, filename, type, filenameSlug) {
filename += filenameSlug;
let file = new Blob([dataStr], {type: type});
var url = URL.createObjectURL(file);
downloadElement.href = url;
downloadElement.download = filename;
}
// Function to download the results as JSON.
downloadJSONRessource(resultFilename, downloadData, downloadElement) {
/**
* Stringify JSON object for json download.
* Use tabs to save some space.
*/
let dataStr = JSON.stringify(downloadData, undefined, "\t");
// Start actual download
this.download(downloadElement, dataStr, resultFilename, "text/json", ".json")
}
}
/**
* Similar to the data class but just intended for meta data about the current
* corpus the client is working with.
*/
class MetaData {
// Sets empty object structure when no input is given.
// if json object like input is given class fields are created from this
init(json={}) {
Object.assign(this, json);
}
}
// Export the classes
export {
Results,
Data,
MetaData
};

View File

@ -0,0 +1,837 @@
/**
* This class implements a ViewEventListener that is listening for the
* specified
*/
class ViewEventListener {
constructor(type, listenerFunction, args=[]) {
this.listenerFunction = listenerFunction;
this.type = type;
this.args = args;
}
}
/**
* This class is implements a View which handles the reprensentation of the
* data that has been fetched by the Client of the corpus_analysis. This view
* only handles how the data is shown to the user. View extends the list.js
* List class.
*/
class ResultsList extends List {
/**
* If no options are given when a new instance of this class is created
* the options below are used.
*/
static options = {
page: 30,
pagination: [{
name: "paginationTop",
paginationClass: "paginationTop",
innerWindow: 8,
outerWindow: 1
}, {
paginationClass: "paginationBottom",
innerWindow: 8,
outerWindow: 1
}],
valueNames: ["titles", "lc", "c", "rc", {data: ["index"]}],
item: `<span></span>`
};
constructor(idOrElement, options) {
super(idOrElement, options);
/**
* All span tokens which are holding events if expert
* mode is on. Collected here to delete later on.
*/
this.eventTokens = {};
/**
* All token elements which have added classes like chip and hoverable for
* expert view. Collected here to delete later on.
*/
this.currentExpertTokenElements = {};
/**
* Holds True/false for check buttons used to add matches to sub-results.
* If checked, it is True. If unchecked, it is false. Buttons for this
* have the class add. The ittle round check buttons to add matches to sub
* results. Key is match index. Value is true or false as mentioned above.
*/
this.subResultsIndexes = {};
// ViewEventListeners listening for client notifications.
this.notificationListeners = {};
this.knownHTMLElements = new Set();
}
/**
* Function to clear/reset some class field values. Usefull if a new query
* hase been issued by the user.
*/
resetFields() {
this.subResultsIndexes = {};
}
/**
* Function that takes one or more query selector
* strings in an array as an input. The function then creates a
* class field in the ResultsList object with the query selector
* string as the key. The selector will be converted to a valid JavaScript
* Field name i. e. #html-id-string -> this.htmlIdString
* The value will be the identifed element or elements fetched with the
* querySelector respectively querySelectorAll method.
* If the query selector is passed as an Array of length 2, with the second
* element defining modal options, teh identified element will be initialized
* as a modal with the given options.
*/
getHTMLElements(arrayOfSelectors) {
for (let selector of arrayOfSelectors) {
// Check if identified Element should be initialized as a modal.
let modalInit = false;
let options;
if (Array.isArray(selector)) {
options = selector[1];
selector = selector[0];
modalInit = true;
}
// Check if the current selector has already been used.
if (this.knownHTMLElements.has(selector)) {
continue;
} else {
// Get element or elements.
let element;
let elements;
if (selector.startsWith('#')) {
element = document.querySelector(selector);
} else {
elements = document.querySelectorAll(selector);
elements = [...elements];
}
// Create valid javascript instance field name.
let cleanKey = [];
selector = selector.replace(/_/g, '-');
selector.match(/\w+/g).forEach((word) => {
let tmp = word[0].toUpperCase() + word.slice(1);
cleanKey.push(tmp);
});
cleanKey[0] = cleanKey[0].toLowerCase();
cleanKey = cleanKey.join('');
this[cleanKey] = element ? element: elements;
// Initialize current element as modal if modalInit true.
if (modalInit) {
this[cleanKey] = M.Modal.init(this[cleanKey], options);
}
}
// Add current selector to knwonHTMLElements.
this.knownHTMLElements.add(selector);
}
}
/**
* Register ViewEventListeners to the ResultsList. Which will listen for
* the specified event.
*/
setViewEventListeners(notificationListeners) {
for (let notificationListener of notificationListeners) {
this.notificationListeners[notificationListener.type] = notificationListener;
}
}
/**
* Loads the ViewEventListeners so that hey will be listening to their
* assigned custom events.
*/
loadViewEventListeners() {
for (let [type, listener] of Object.entries(this.notificationListeners)) {
if (listener.args.length > 0) {
listener.listenerFunction(...listener.args);
} else {
listener.listenerFunction(type, this);
}
}
}
/**
* This functions sends events to the Client to trigger specific functions to
* trigger new data requests from the server.
*/
notifyClient(caseIdentifier, detailObject={}) {
detailObject.caseIdentifier = caseIdentifier;
const event = new CustomEvent('notify-client', { detail: detailObject });
console.info('Client dispatching Notification:', caseIdentifier);
document.dispatchEvent(event);
}
/**
* Creates cpos either from ranges or not.
*/
helperCreateCpos(cpos_values) {
let lc, c, rc;
/**
* Python range like function from MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/from#Sequence_generator_(range)
*/
const range = (start, stop, step) => Array.from({ length: (stop - start) / step + 1}, (_, i) => start + (i * step));
lc = cpos_values.lc ? range(cpos_values.lc[0], cpos_values.lc[1], 1) : [];
c = range(cpos_values.c[0], cpos_values.c[1], 1);
rc = cpos_values.rc ? range(cpos_values.rc[0], cpos_values.rc[1], 1) : [];
return {lc: lc, c: c, rc: rc};
}
// Get display options from display options form element.
static getDisplayOptions(htmlId) {
// gets display options parameters
let displayOptionsFormElement = document.getElementById(htmlId);
let displayOptionsFormData = new FormData(displayOptionsFormElement);
let displayOptionsData =
{
"resultsPerPage": displayOptionsFormData.get("display-options-form-results_per_page"),
"resultsContex": displayOptionsFormData.get("display-options-form-result_context"),
"expertMode": displayOptionsFormData.get("display-options-form-expert_mode")
};
return displayOptionsData
}
/**
* Used in addToSubResults and inspect to toggle the design of the check
* buttons according to its checked unchecked status.
*/
helperActivateAddBtn(btn) {
btn.classList.remove("corpus-analysis-color.lighten");
btn.classList.add("green");
btn.textContent = "check";
}
/**
* Used in addToSubResults and inspect to toggle the design of the check
* buttons according to its checked unchecked status.
*/
helperDeactivateAddBtn(btn) {
btn.classList.remove("green");
btn.classList.add("corpus-analysis-color.lighten");
btn.textContent = "add";
}
/**
* This function is invoked when the users adds or removes a match using the
* add-btn (+ button/or green checkmark) to/from sub-results. When the button
* is clicked the function checks if the current dataIndex ID is already
* saved in subResultsIndexes or not. If it is not the dataIndex will be used
* as a key in subResultsIndexes with the value true. If it is already added
* the entry with the key dataIndex will be deleted from subResultsIndexes.
* Visual feedback (green checkmark if a match has been added etc.) is also
* handled on the basis of the information stored in subResultsIndexes.
*/
addToSubResults(dataIndex, client, tableCall=true) {
let toShowArray;
dataIndex = parseInt(dataIndex);
if (!this.subResultsIndexes[dataIndex]
|| this.subResultsIndexes[dataIndex] === undefined) {
// add button is activated because status is false or undefined
this.helperActivateAddBtn(event.target);
this.subResultsIndexes[dataIndex] = true;
toShowArray = Object.keys(this.subResultsIndexes).map(index => parseInt(index));
// Add 1 because indexes are zero based. User sees 1 based numbering.
toShowArray = toShowArray.map(index => index + 1);
// Allways sort the shown indexes for the user if new match is added.
toShowArray = toShowArray.sort(function(a, b){return a-b});
this.subResultsIndexesDisplay.textContent = toShowArray.join(', ');
M.textareaAutoResize(this.subResultsIndexesDisplay);
this.nrMarkedMatches.textContent = Object.keys(this.subResultsIndexes).length;
} else if (this.subResultsIndexes[dataIndex]) {
// add button is deactivated because status is true
this.helperDeactivateAddBtn(event.target);
delete this.subResultsIndexes[dataIndex];
toShowArray = Object.keys(this.subResultsIndexes).map(index => parseInt(index));
// Add 1 because indexes are zero based. User sees 1 based numbering.
toShowArray = toShowArray.map(index => index + 1);
// Allways sort the shown indexes for the user if new match is added.
toShowArray = toShowArray.sort(function(a, b){return a-b});
this.subResultsIndexesDisplay.textContent = toShowArray.join(', ');
this.nrMarkedMatches.textContent = Object.keys(this.subResultsIndexes).length;
M.textareaAutoResize(this.subResultsIndexesDisplay);
}
// Toggles create button according to the number of ids in addToSubResultsIdsToShow
if (Object.keys(this.subResultsIndexes).length > 0 && !client.isBusy) {
this.subResultsCreate.classList.toggle('disabled', false);
} else if (Object.keys(this.subResultsIndexes).length === 0) {
this.subResultsCreate.classList.toggle('disabled', true);
}
/**
* After a match as been added or removed the export button will be
* hidden because the sub-results have been altered and have to be built
* again. Thus subResultsCreateElement has to be shown again.
*/
this.subResultsExport.classList.add("hide");
this.subResultsCreate.classList.remove("hide");
/**
* Also activate/deactivate buttons in the table/resultsList accordingly
* if button in inspect was activated/deactivated.
* This part only runs if tableCall is set to false when this function is
* called.
*/
if (!tableCall) {
this.getHTMLElements(['#query-results-table']);
let container = this.queryResultsTable.querySelector(`[data-index="${dataIndex}"]`);
let tableAddBtn = container.querySelector('.add-btn'); // gets the add button from the list view
if (this.subResultsIndexes[dataIndex]) {
this.helperActivateAddBtn(tableAddBtn);
} else {
this.helperDeactivateAddBtn(tableAddBtn);
}
}
}
// Toggle inspect buttons depending on the Client status
toggleInspectButtons(client) {
if (!client.isBusy) {
this.activateInspectButtons();
} else if (client.isBusy) {
this.deactivateInspectButtons();
}
}
// Helper function. Should be private if feature is available.
activateInspectButtons() {
let inspectBtnElements;
inspectBtnElements = document.querySelectorAll('.inspect');
for (let inspectBtn of inspectBtnElements) {
inspectBtn.classList.toggle('disabled', false);
}
}
// Helper function. Should be private if feature is available.
deactivateInspectButtons() {
let inspectBtnElements;
inspectBtnElements = document.querySelectorAll('.inspect');
for (let inspectBtn of inspectBtnElements) {
inspectBtn.classList.toggle('disabled', true);
}
}
/**
* Function to inspect one match in more detail (Showing more context).
* If in dynamic mode the view notifies the client to requests the new
* context for the one match identified by the given dataIndex.
* If not in dynamic mode the the needed context will be gathered from the
* already present results in results.data.
*/
inspect(client, results, dataIndex, type) {
// initialize context modal
this.getHTMLElements([
['#context-modal', true],
'#context-results',
'#create-inspect-menu',
'#create-from-inspect',
]);
// Clear fields from old data on every new inspect() call.
this.contextId = dataIndex[0];
this.contextResults.innerHTML = '';
// Open modal.
this.contextModal.open();
this.contextResults.insertAdjacentHTML('afterbegin', `
<div class="progress">
<div class="indeterminate"></div>
</div>
`);
if (client.dynamicMode) {
// Notify Client to get results from server.
this.notifyClient('get-results', {resultsType: 'inspect-results',
dataIndexes: dataIndex,
resultsList: this});
} else {
// Gather results data from already present data.
results.inspectResultsData.matches = [results.data.matches[dataIndex[0]]];
results.inspectResultsData.cpos_ranges = results.data.cpos_ranges;
this.showMatchContext(results, client)
}
// Match nr for user to display derived from data_index.
let contextMatchNrElement = document.getElementById("context-match-nr");
contextMatchNrElement.textContent = this.contextId + 1;
// Add the add button to add this match to sub results with onclick event.
let classes = `btn-floating btn waves-effect` +
` waves-light corpus-analysis-color.lighten right`
let addToSubResultsIdsBtn = document.createElement("a");
addToSubResultsIdsBtn.setAttribute("class", classes + ` add`);
addToSubResultsIdsBtn.innerHTML = '<i class="material-icons">add</i>';
addToSubResultsIdsBtn.onclick= () => {
this.addToSubResults(dataIndex[0], client, false)
};
/**
* Checks if the match has or has not been added to sub results yet.
* Sets the color and status of the button accordingly.
*/
if (this.subResultsIndexes[dataIndex[0]]) {
this.helperActivateAddBtn(addToSubResultsIdsBtn.firstElementChild);
} else if (!this.subResultsIndexes[dataIndex[0]]) {
this.helperDeactivateAddBtn(addToSubResultsIdsBtn.firstElementChild);
}
this.createInspectMenu.innerHTML = '';
this.createInspectMenu.appendChild(addToSubResultsIdsBtn);
// Hide create menu if not in dynamic mode.
if (!client.dynamicMode) {
this.createFromInspect.classList.add('hide');
}
}
/**
* Create Element from HTML String. Helper function should be private.
* https://stackoverflow.com/questions/494143/creating-a-new-dom-element-from-an-html-string-using-built-in-dom-methods-or-pro/35385518#35385518
*/
HTMLTStrToElement(htmlStr) {
let template = document.createElement("template");
htmlStr = htmlStr.trim();
template.innerHTML = htmlStr;
return template.content.firstChild;
}
/**
* Used either as a callback if the client has been notified to get new
* results with new full context. Or just directly invoced as a function
* with the according input data.
*/
showMatchContext(results, client) {
this.getHTMLElements([
'#context-results',
'#inspect-display-options-form-expert_mode_inspect',
'#inspect-display-options-form-highlight_sentences',
'#context-sentences'
])
let uniqueS = new Set();
let uniqueContextS = new Set();
let {lc, c, rc} = this.helperCreateCpos(results.inspectResultsData.matches[0]);
// Create sentence strings as tokens.
let tokenHTMLArray = [];
let htmlTokenStr = ``;
let tokenHTMlElement;
let token;
for (let cpos of lc) {
if (client.dynamicMode) {
token = results.inspectResultsData.cpos_lookup[cpos];
// If client is not in dynamic mode use cpos_lookup from results.data
} else {
token = results.data.cpos_lookup[cpos];
}
uniqueS.add(token.s)
htmlTokenStr = `<span class="token"` +
`data-sid="${token.s}"` +
`data-cpos="${cpos}">` +
`${token.word}` +
`</span>`;
tokenHTMlElement = this.HTMLTStrToElement(htmlTokenStr)
tokenHTMLArray.push(tokenHTMlElement);
}
for (let cpos of c) {
if (client.dynamicMode) {
token = results.inspectResultsData.cpos_lookup[cpos];
// If client is not in dynamic mode use cpos_lookup from results.data
} else {
token = results.data.cpos_lookup[cpos];
}
uniqueContextS.add(token.s);
uniqueS.add(token.s);
htmlTokenStr = `<span class="token bold light-green"` +
`data-sid="${token.s}"` +
`data-cpos="${cpos}"` +
`style="text-decoration-line: underline;">` +
`${token.word}` +
`</span>`;
tokenHTMlElement = this.HTMLTStrToElement(htmlTokenStr)
tokenHTMLArray.push(tokenHTMlElement);
}
results.inspectResultsData["context_s_ids"] = Array.from(uniqueContextS);
for (let cpos of rc) {
if (client.dynamicMode) {
token = results.inspectResultsData.cpos_lookup[cpos];
// If client is not in dynamic mode use cpos_lookup from results.data
} else {
token = results.data.cpos_lookup[cpos];
}
uniqueS.add(token.s)
htmlTokenStr = `<span class="token"` +
`data-sid="${token.s}"` +
`data-cpos="${cpos}">` +
`${token.word}` +
`</span>`;
tokenHTMlElement = this.HTMLTStrToElement(htmlTokenStr)
tokenHTMLArray.push(tokenHTMlElement);
}
// Remove loading indeterminate HTML before context is inserted.
this.contextResults.innerHTML = '';
for (let sId of uniqueS) {
let htmlSentence = `<span class="sentence" data-sid="${sId}"></span>`;
let sentenceElement = this.HTMLTStrToElement(htmlSentence);
for (let tokenElement of tokenHTMLArray) {
if (tokenElement.dataset.sid == sId) {
sentenceElement.appendChild(tokenElement);
sentenceElement.insertAdjacentHTML("beforeend", ` `);
} else {
continue;
}
}
this.contextResults.appendChild(sentenceElement);
}
// Add expert mode switch event for the modal to toggle expert mode.
this.inspectDisplayOptionsFormExpertModeInspect.onchange = (event) => {
if (event.target.checked) {
this.expertModeOn("context-results", results);
} else {
this.expertModeOff("context-results")
}
};
// Add switch event to toggle Sentence highlighting.
this.inspectDisplayOptionsFormHighlightSentences.onchange = (event) => {
if (event.target.checked) {
this.higlightContextSentences();
} else {
this.unhighlightContextSentences();
}
};
// Add range event to change nr of context sentences.
this.contextSentences.onchange = (event) => {
this.changeSentenceContext(event.target.value);
}
/**
* Checks on new modal opening if switches are checked
* if switches are checked functions are executed.
*/
if (this.inspectDisplayOptionsFormExpertModeInspect.checked) {
this.expertModeOn("context-results", results);
}
if (this.inspectDisplayOptionsFormHighlightSentences.checked) {
this.higlightContextSentences();
}
/**
* Checks the value of the number of sentences to show on modal opening
* sets context sentences accordingly
*/
this.changeSentenceContext(this.contextSentences.value);
}
// Splits context text into sentences based on spacy sentence split
higlightContextSentences() {
let sentences = document.getElementById("context-results").getElementsByClassName("sentence");
for (let s of sentences) {
s.insertAdjacentHTML("beforeend", `<span><br><br></span>`)
}
}
// Reverse operation of above function.
unhighlightContextSentences() {
let sentences = document.getElementById("context-results").getElementsByClassName("sentence");
let br;
for (let s of sentences) {
br = s.lastChild;
br.remove();
}
}
// Changes how many context sentences in inspect view are shown.
changeSentenceContext(sValue, maxSValue=10) {
sValue = maxSValue - sValue;
// console.log(sValue);
let sentences = document.getElementById("context-results").getElementsByClassName("sentence");
let array = Array.from(sentences);
let toHideArray;
let toShowArray;
if (sValue != 0) {
toHideArray = array.slice(0, sValue).concat(array.slice(-(sValue)));
toShowArray = array.slice(sValue, 9).concat(array.slice(9, -(sValue)))
} else {
toHideArray = [];
toShowArray = array;
}
for (let s of toHideArray) {
s.classList.add("hide");
}
for (let s of toShowArray) {
s.classList.remove("hide");
}
}
// ###### Display options functions changing how results are being displayed ######
/**
* Event function that changes the shown hits per page.
* Just alters the resultsList.page property.
*/
changeHitsPerPage(client, results) {
this.page = this.displayOptionsFormResultsPerPage.value;
this.update();
this.changeContext();
this.toggleInspectButtons(client);
if (this.displayOptionsFormExpertMode.checked) {
this.expertModeOn('query-display', results);
}
}
/**
* Event function triggered on context select change also if pagination is
* clicked.
*/
changeContext() {
let newContextValue = this.displayOptionsFormResultContext.value;
let lc = document.querySelectorAll(".left-context");
let rc = document.querySelectorAll(".right-context");
for (let element of lc) {
let arrayLc = Array.from(element.childNodes);
for (let element of arrayLc.reverse().slice(newContextValue)) {
element.classList.add("hide");
}
for (let element of arrayLc.slice(0, newContextValue)) {
element.classList.remove("hide");
}
}
for (let element of rc) {
let arrayRc = Array.from(element.childNodes);
for (let element of arrayRc.slice(newContextValue)) {
element.classList.add("hide");
}
for (let element of arrayRc.slice(0, newContextValue)) {
element.classList.remove("hide");
}
}
}
// ###### Expert view event functions ######
// Function to create a tooltip for the current hovered token.
tooltipEventCreate(event, results) {
let token = results.data.cpos_lookup[event.target.dataset.cpos];
if (!token) {
token = results.inspectResultsData.cpos_lookup[event.target.dataset.cpos];
}
this.currentTooltipElement = M.Tooltip.init(event.target, {
'html': `<table>
<tr>
<th>Token information</th>
<th>Source information</th>
</tr>
<tr>
<td class="left-align">
Word: ${token.word}<br>
Lemma: ${token.lemma}<br>
POS: ${token.pos}<br>
Simple POS: ${token.simple_pos}<br>
NER: ${token.ner}
</td>
<td class="left-align">
Title: ${results.data.text_lookup[token.text].title}
<br>
Author: ${results.data.text_lookup[token.text].author}
<br>
Publishing year: ${results.data.text_lookup[token.text].publishing_year}
</td>
</tr>
</table>`
}
);
}
/**
* Function to destroy the current Tooltip for the current hovered tooltip
* on mouse leave
*/
tooltipEventDestroy(event) {
this.currentTooltipElement.destroy();
}
/**
* Turn the expert mode on for all tokens in the DOM element identified by
* its htmlID.
*/
expertModeOn(htmlId, results) {
if (!Array.isArray(this.currentExpertTokenElements[htmlId])) {
this.currentExpertTokenElements[htmlId] = [];
}
let container = document.getElementById(htmlId);
let tokens = container.querySelectorAll('span.token');
this.currentExpertTokenElements[htmlId].push(...tokens);
this.eventTokens[htmlId] = [];
for (let tokenElement of this.currentExpertTokenElements[htmlId]) {
tokenElement.classList.add('chip', 'hoverable', 'expert-view');
const eventCreate = (event, arg) => this.tooltipEventCreate(event, arg);
tokenElement.onmouseover = (event) => eventCreate(event, results);
tokenElement.onmouseout = (event) => this.tooltipEventDestroy(event);
this.eventTokens[htmlId].push(tokenElement);
}
}
/**
* Turn the expert mode off for all tokens in the DOM element identified by
* its htmlID.
*/
expertModeOff(htmlId) {
if (!Array.isArray(this.currentExpertTokenElements[htmlId])) {
this.currentExpertTokenElements[htmlId] = [];
}
if (!Array.isArray(this.eventTokens[htmlId])) {
this.eventTokens[htmlId] = [];
}
for (let tokenElement of this.currentExpertTokenElements[htmlId]) {
tokenElement.classList.remove("chip", "hoverable", "expert-view");
}
this.currentExpertTokenElements[htmlId] = [];
for (let eventToken of this.eventTokens[htmlId]) {
eventToken.onmouseover = '';
eventToken.onmouseout = '';
}
this.eventTokens[htmlId] = [];
}
createResultRowElement(item, chunk, client, imported=false) {
// Gather values from item.
let values = item.values();
let {lc, c, rc} = this.helperCreateCpos(values)
// Get infos for full match row.
let matchRowElement = document.createElement("tr");
matchRowElement.setAttribute("data-index", values.index)
let lcCellElement = document.createElement("td");
lcCellElement.classList.add("left-context");
matchRowElement.appendChild(lcCellElement);
for (let cpos of lc) {
let token = chunk.cpos_lookup[cpos];
lcCellElement.insertAdjacentHTML("beforeend",
`<span class="token" data-cpos="${cpos}">${token.word} </span>`);
}
// Get infos for hit of match and set actions.
let textTitles = new Set();
let aCellElement = document.createElement("td");
aCellElement.classList.add("actions");
let cCellElement = document.createElement("td");
cCellElement.classList.add("match-hit");
let textTitlesCellElement = document.createElement("td");
textTitlesCellElement.classList.add("titles");
let matchNrElement = document.createElement("td");
matchNrElement.classList.add("match-nr");
matchRowElement.appendChild(cCellElement);
matchRowElement.appendChild(aCellElement);
for (let cpos of c) {
let token = chunk.cpos_lookup[cpos];
cCellElement.insertAdjacentHTML("beforeend",
`<span class="token" data-cpos="${cpos}">${token.word} </span>`);
// Get text titles of every hit cpos token.
textTitles.add(chunk.text_lookup[token.text].title);
}
// Add some interaction buttons.
let css = `margin-right: 5px; margin-bottom: 5px;`
let classes = `btn-floating btn waves-effect` +
` waves-light corpus-analysis-color.lighten`
// Add inspect button to trigger inspect view with more context.
let inspectBtn = document.createElement("a");
inspectBtn.setAttribute("style", css);
inspectBtn.setAttribute("class", classes + ` disabled inspect`
);
inspectBtn.innerHTML = '<i class="material-icons inspect-btn">search</i>';
// Add btn to be able add matches to sub-results.
let addToSubResultsBtn = document.createElement("a");
addToSubResultsBtn.setAttribute("style", css);
addToSubResultsBtn.setAttribute("class", classes + ` add`);
addToSubResultsBtn.innerHTML = '<i class="material-icons add-btn">add</i>';
if (client.dynamicMode || client.fullContext) {
aCellElement.appendChild(inspectBtn);
}
if (client.dynamicMode) {
aCellElement.appendChild(addToSubResultsBtn);
}
// Add text titles at front as first td of one row.
textTitlesCellElement.textContent = [...textTitles].join(", ");
matchRowElement.insertAdjacentHTML("afterbegin", textTitlesCellElement.outerHTML);
matchNrElement.textContent = values.index + 1;
matchRowElement.insertAdjacentHTML("afterbegin", matchNrElement.outerHTML);
// Get infos for right context of match
let rcCellElement = document.createElement("td");
rcCellElement.classList.add("right-context");
matchRowElement.appendChild(rcCellElement);
for (let cpos of rc) {
let token = chunk.cpos_lookup[cpos];
rcCellElement.insertAdjacentHTML("beforeend",
`<span class="token" data-cpos="${cpos}">${token.word} </span>`);
}
return matchRowElement
}
/**
* Creates the HTML table code for the metadata view in the corpus analysis
* interface
*/
createMetaDataForModal(metaDataObject) {
let html = `<div class="col s12">
<table class="highlight">
<thead>
<tr>
<th>Meta Data Description</th>
<th>Value</th>
</tr>
</thead>
<tbody>`
for (let [outerKey, outerValue] of Object.entries(metaDataObject)) {
// Use more descriptive names.
if (outerKey === 'corpus_all_texts') {
let tmpName = 'All texts in this corpus';
html += `<tr>
<td style="text-transform: uppercase;">${tmpName.replace(/_/g, " ")}
</td>`
} else if (outerKey === 'text_lookup') {
let tmpName = 'Texts where the query resulted in matches'
html += `<tr>
<td style="text-transform: uppercase;">${tmpName.replace(/_/g, " ")}
</td>`
} else {
html += `<tr>
<td style="text-transform: uppercase;">${outerKey.replace(/_/g, " ")}
</td>`
}
if (outerKey === "corpus_all_texts" || outerKey === "text_lookup") {
html += `<td>
<ul class="collapsible">`
for (let [innerKey, innerValue] of Object.entries(outerValue)) {
html += `<li class="text-metadata"
data-metadata-key="${outerKey}"
data-text-key="${innerKey}">
<div class="collapsible-header"
data-metadata-key="${outerKey}"
data-text-key="${innerKey}">
<i class="material-icons"
data-metadata-key="${outerKey}"
data-text-key="${innerKey}">info_outline</i>
${innerValue['author']} - ${innerValue['publishing_year']} -
${innerValue['title']}
</div>
<div class="collapsible-body">
<span>
<ul id="bibliographic-data-${outerKey}-${innerKey}"
style="column-count: 2;">
</ul>
</span>
</div>
</li>`
}
html += `</ul>
</td>`
} else {
html += `<td>${outerValue}</td>`
}
html += `</tr>`
}
html += `</tbody>
</table>`
return html
}
/**
* Creates the text details for the texts shown in the corpus analysis
* metadata modal table.
*/
createTextDetails(metaData) {
let metadataKey = event.target.dataset.metadataKey;
let textKey = event.target.dataset.textKey;
let textData = metaData[metadataKey][textKey];
let bibliographicData = document.querySelector(`#bibliographic-data-${metadataKey}-${textKey}`);
bibliographicData.textContent = '';
for (let [key, value] of Object.entries(textData)) {
bibliographicData.insertAdjacentHTML("afterbegin",
`
<li>
<span style="text-transform: capitalize;">${key}:</span>
${value}
</li>
`
);
}
}
};
// Export classes.
export {
ViewEventListener,
ResultsList
};

View File

@ -0,0 +1,244 @@
/**
* This file contains all the callbacks triggered by the notificationListener.
* Also general callbacks are defined which are doing some hiding/disabling and
* showing/enabling of common elements when data is being transmitted or not.
*/
// Callback to disable some elements for the user when the client is busy.
function disableElementsGeneralCallback(resultsList, detail) {
if (detail.client.isBusy) {
resultsList.fullResultsCreate.classList.toggle('disabled', true);
resultsList.subResultsCreate.classList.toggle('disabled', true);
resultsList.toggleInspectButtons(detail.client);
}
}
// Callback to enable some elements for the user when the client is not busy.
function enableElementsGeneralCallback(resultsList, detail) {
if (!detail.client.isBusy) {
resultsList.fullResultsCreate.classList.toggle('disabled', false);
if (Object.keys(resultsList.subResultsIndexes).length > 0) {
resultsList.subResultsCreate.classList.toggle('disabled', false);
}
resultsList.toggleInspectButtons(detail.client);
}
}
/**
* Callback opening the loading modal when the client is connecting to the
* CQP server.
*/
function connectingCallback(resultsList, detail) {
resultsList.getHTMLElements(['#analysis-init-modal']);
resultsList.analysisInitModal = M.Modal.init(resultsList.analysisInitModal,
{dismissible: false});
resultsList.analysisInitModal.open();
}
// Callback that closes the loading modal from above.
function connectedCallback(resultsList, detail) {
/**
* In the past this closed the init modal. But the init modal is now being
* closed when the meta data has also been recieved. See below.
*/
}
// Callback that closes the loading modal from above.
function metaDataRecievedCallback(resultsList, detail) {
resultsList.analysisInitModal.close();
}
// Callback that shows the user some feedback if the client raised an error.
function clientFailedCallback(resultsList, detail) {
resultsList.getHTMLElements([
'#analysis-init-progress',
'#analysis-init-error',
'#user-feedback',
]);
if (detail.raiseModalFeedback) {
resultsList.analysisInitModal.open();
resultsList.analysisInitProgress.classList.toggle('hide');
resultsList.analysisInitError.classList.toggle('hide');
resultsList.analysisInitError.textContent = detail.msg;
} else {
nopaque.appClient.flash(detail.msg, 'error')
}
}
// Callback doing some preperation work if a query has been issued by the user.
function queryDataPreparingCallback(resultsList, detail) {
// remove all items from resultsList, like from the query issued before
resultsList.clear()
// get needed HTML Elements
let results = detail.results;
resultsList.getHTMLElements([
'#interactions-menu',
'#recieved-match-count',
'#total-match-count',
'#text-lookup-titles',
'#text-lookup-count',
'#query-results-user-feedback',
'#query-progress-bar',
'#query-results-create',
'#sub-results-indexes-display',
'#nr-marked-matches',
]);
// show or enable some things for the user
resultsList.interactionsMenu.classList.toggle('hide', false);
resultsList.queryResultsUserFeedback.classList.toggle('hide', false);
resultsList.queryProgressBar.classList.toggle('hide', false);
resultsList.showCorpusFiles.classList.toggle('disabled', true);
/**
* Set some initial values for the user feedback
* or reset values for new issued query
*/
resultsList.recievedMatchCount.textContent = 0;
resultsList.totalMatchCount.textContent = results.data.match_count;
resultsList.textLookupCount.textContent = 0;
resultsList.nrMarkedMatches.textContent = 0;
resultsList.subResultsIndexesDisplay.textContent = '';
resultsList.resetFields();
}
/**
* Callback handling the incoming results of an issued query. It renders
* the incoming matches using the resultsList for the user.
*/
function queryDataRecievingCallback(resultsList, detail) {
// load the data into the resultsList and show them to the user
let results = detail.results;
let client = detail.client;
let start = detail.dataLength;
let resultItems = [];
for (let [index, match] of Object.entries(results.data.matches).slice(start)) {
resultItems.push({ ...match, ...{ 'index': parseInt(index) } });
}
if (client.dynamicMode) {
resultsList.add(resultItems, (items) => {
for (let item of items) {
item.elm = resultsList.createResultRowElement(item,
results.data,
client);
}
});
// update user feedback about query status
resultsList.recievedMatchCount.textContent = results.data.matches.length;
resultsList.queryProgressBar.firstElementChild.style.width = `${client.requestQueryProgress}%`;
resultsList.textLookupCount.textContent = `${Object.keys(results.data.text_lookup).length}`;
// updating table on finished item creation callback via createResultRowElement
resultsList.update();
resultsList.changeHitsPerPage(client, results);
resultsList.changeContext();
//activate expertMode of switch is checked
if (resultsList.displayOptionsFormExpertMode.checked) {
resultsList.expertModeOn('query-display', results);
}
} else if (!client.dynamicMode) {
resultsList.add(resultItems, (items) => {
for (let item of items) {
item.elm = resultsList.createResultRowElement(item,
results.data,
client,
true);
}
});
// update user feedback about query status
resultsList.recievedMatchCount.textContent = results.data.matches.length;
resultsList.queryProgressBar.firstElementChild.style.width = `${client.requestQueryProgress}%`;
resultsList.textLookupCount.textContent = `${Object.keys(results.data.text_lookup).length}`;
// updating table on finished item creation callback via createResultRowElement
resultsList.update();
resultsList.changeHitsPerPage(client, results);
resultsList.changeContext();
}
}
// Callback that is executed when all results from an issued query have been recieved
function queryDataRecievedCallback(resultsList, detail) {
// hide or disable some things for the user
resultsList.queryResultsUserFeedback.classList.toggle('hide', true);
resultsList.queryProgressBar.classList.toggle('hide', true);
// reset bar progress for next query
resultsList.queryProgressBar.firstElementChild.style.width = '0%';
resultsList.showCorpusFiles.classList.toggle('disabled', false);
}
/**
* Callback that is handling incoming results data. Results data is needed for
* the export and download of the data.
*/
function resultsDataRecievingCallback(resultsList, detail) {
resultsList.getHTMLElements([
'#full-results-progress-bar',
'#sub-results-progress-bar',
]);
// Disable the full context switch when results are being recieved.
resultsList.exportFullInspectContext.setAttribute('disabled', '');
if (detail.type === 'full-results' && detail.progress) {
resultsList.fullResultsProgressBar.firstElementChild.style.width = `${detail.progress}%`;
resultsList.fullResultsProgressBar.classList.toggle('hide', false);
} else if (detail.type === 'sub-results' && detail.progress) {
resultsList.subResultsProgressBar.firstElementChild.style.width = `${detail.progress}%`;
resultsList.subResultsProgressBar.classList.toggle('hide', false);
}
}
/**
* Callback is executed when all results data has been recieved.
* Reactivates the resutls create buttons etc.
*/
function resultsDataRecievedCallback(resultsList, detail) {
// create strings for create buttons depending on type
const handleType = (keyPrefix, text) => {
// Enable the full context switch when results have been recieved
resultsList.exportFullInspectContext.removeAttribute('disabled', '');
// hides the create element after results have been recieved and reset it
resultsList[`${keyPrefix}Create`].classList.toggle('hide');
resultsList[`${keyPrefix}Create`].textContent = `Create ${text}`;
resultsList[`${keyPrefix}Create`].insertAdjacentHTML('beforeend',
`<i class="material-icons left">build</i>`);
// show and highlight export button
resultsList[`${keyPrefix}Export`].classList.toggle('hide', false);
resultsList[`${keyPrefix}Export`].classList.toggle('pulse', true);
setTimeout(() => {
resultsList[`${keyPrefix}Export`].classList.toggle('pulse', false);
clearTimeout();
}, 3000)
}
if (detail.type === 'full-results') {
handleType('fullResults', 'Results');
if (detail.fullContext) {
resultsList.fullResultsProgressBar.firstElementChild.style.width = `0%`;
resultsList.fullResultsProgressBar.classList.toggle('hide', true);
}
} else if (detail.type ==='sub-results') {
handleType('subResults', 'Sub-Results');
if (detail.fullContext) {
resultsList.subResultsProgressBar.firstElementChild.style.width = `0%`;
resultsList.subResultsProgressBar.classList.toggle('hide', true);
}
} else if (detail.type ==='inspect-results') {
if (Object.keys(resultsList.subResultsIndexes).length === 0) {
/**
* Prevent create sub results button from being activated if it is disabled
* and no matches have been marked by the user for sub results creation.
*/
resultsList.subResultsCreate.classList.toggle('disabled', true);
}
resultsList.showMatchContext(detail.results, detail.client);
}
}
// export the callbacks
export {
connectingCallback,
connectedCallback,
metaDataRecievedCallback,
clientFailedCallback,
queryDataPreparingCallback,
queryDataRecievingCallback,
queryDataRecievedCallback,
resultsDataRecievingCallback,
resultsDataRecievedCallback,
disableElementsGeneralCallback,
enableElementsGeneralCallback,
};

View File

@ -0,0 +1,378 @@
/**
* 1.)
* This file contains the listener function that will be assigned to the
* corpus_analysis ResultsView. The listener is listening for the notification
* event which is being dispatched by the corpus_analysis Client. The
* notification Event triggers the listener which will call different
* callback functions depending on the detail information of the notification
* event.
* 2.)
* This file also contains vanilla javascript Event listeners which are
* listening for button clicks etc. the user is doing for page interaction.
* They will be registered the same way as teh listeners above.
*/
import {
connectingCallback,
connectedCallback,
metaDataRecievedCallback,
clientFailedCallback,
queryDataPreparingCallback,
queryDataRecievingCallback,
queryDataRecievedCallback,
resultsDataRecievingCallback,
resultsDataRecievedCallback,
disableElementsGeneralCallback,
enableElementsGeneralCallback,
} from './callbacks.js';
// Import the script that implements a spinner animation for buttons.
import {
loadingSpinnerHTML,
} from './spinner.js';
/**
* The Listener listening for the notification event 'notify-view' dispatched
* by the client and execeutes callbacks accordingly.
*/
function recieveClientNotification(eventType, resultsList) {
document.addEventListener(eventType, (event) => {
let caseIdentifier = event.detail.caseIdentifier;
switch (caseIdentifier) {
case 'client-failed':
console.error('View recieved notification:', caseIdentifier);
// execute callbacks
clientFailedCallback(resultsList, event.detail);
break;
case 'connecting':
console.info('View recieved notification:', caseIdentifier);
connectingCallback(resultsList, event.detail);
// execute callbacks
break;
case 'connected':
console.info('View recieved notification:', caseIdentifier);
connectedCallback(resultsList, event.detail);
break;
case 'meta-data-recieving':
console.info('View recieved notification:', caseIdentifier);
break;
case 'meta-data-recieved':
console.info('View recieved notification:', caseIdentifier);
// execute
metaDataRecievedCallback(resultsList, event.detail);
break;
case 'query-data-prepareing':
console.info('View recieved notification:', caseIdentifier);
// Hide all download buttons
resultsList.fullResultsExport.classList.toggle('hide', true);
resultsList.subResultsExport.classList.toggle('hide', true);
// Show all create buttons
resultsList.fullResultsCreate.classList.toggle('hide', false);
resultsList.subResultsCreate.classList.toggle('hide', false);
// execute callbacks
disableElementsGeneralCallback(resultsList, event.detail);
queryDataPreparingCallback(resultsList, event.detail);
break;
case 'query-data-recieving':
console.info('View recieved notification:', caseIdentifier);
// execute callbacks
queryDataRecievingCallback(resultsList, event.detail);
break;
case 'query-data-recieved':
console.info('View recieved notification:', caseIdentifier);
// execute callbacks
queryDataRecievedCallback(resultsList, event.detail);
enableElementsGeneralCallback(resultsList, event.detail);
// create sub-results is disabled per default until matches have been added
resultsList.subResultsCreate.classList.toggle('disabled', true);
break;
case 'results-data-recieving':
console.info('View recieved notification:', caseIdentifier);
// execute callbacks
disableElementsGeneralCallback(resultsList, event.detail);
resultsDataRecievingCallback(resultsList, event.detail);
break;
case 'results-data-recieved':
console.info('View recieved notification:', caseIdentifier);
// execute callbacks
console.info(event.detail);
resultsDataRecievedCallback(resultsList, event.detail);
enableElementsGeneralCallback(resultsList, event.detail);
break;
default:
console.error('Recieved unkown notification case identifier from Client');
// do something to not crash the analysis session?
}
});
}
/**
* This are some vanilla javascript Event listeners which are listening
* for button clicks etc. the user is doing to interact with the page.
* They will be registered the same way as the listeners above.
*/
/**
* The following listener handles what functions are called when the user
* does use the page navigation to navigate to a new page.
*/
function pageNavigation(resultsList, results, client) {
for (let element of resultsList.pagination) {
element.addEventListener("click", (event) => {
// Shows match context according to the user picked value on a new page.
resultsList.changeContext();
// De- or activates expertMode on new page depending on switch value.
if (resultsList.displayOptionsFormExpertMode.checked) {
resultsList.expertModeOn('query-display', results);
} else {
resultsList.expertModeOff('query-display');
}
// Activates inspect buttons on new page if client is not busy.
resultsList.toggleInspectButtons(client);
});
}
}
/**
* The following event Listener handles the expert mode switch for the list.
*/
function expertModeSwitch(resultsList, results) {
resultsList.displayOptionsFormExpertMode.onchange = (event) => {
if (event.target.checked) {
resultsList.expertModeOn('query-display', results);
} else {
resultsList.expertModeOff('query-display');
}
};
}
/**
* The following event Listener handles the add-btn and the inspect-btn
* onclick events via bubbleing.
*/
function actionButtons(resultsList, results, client) {
resultsList.queryResultsTable.addEventListener('click', (event) => {
let dataIndex;
if (event.target.classList.contains('inspect-btn')) {
dataIndex = parseInt(event.target.closest('tr').dataset.index);
resultsList.inspect(client, results, [dataIndex], 'inspect');
} else if (event.target.classList.contains('add-btn')) {
dataIndex = parseInt(event.target.closest('tr').dataset.index);
resultsList.addToSubResults(dataIndex, client);
}
})
}
/**
* Following event listeners handle the change of Context size per match and
* the number of matches shown per page.
*/
function displayOptions(resultsList, results, client) {
resultsList.displayOptionsFormResultsPerPage.onchange = (event) => {
resultsList.changeHitsPerPage(client, results);
};
resultsList.displayOptionsFormResultContext.onchange = (event) => {
resultsList.changeContext();
};
}
/**
* The following event listener handles the show metadata button and its
* functionality.
*/
function showMetaData(resultsList, results) {
resultsList.showMetaData.onclick = () => {
resultsList.metaDataModalContent.textContent = '';
let table = resultsList.createMetaDataForModal(results.metaData);
resultsList.metaDataModalContent.insertAdjacentHTML('afterbegin', table);
resultsList.metaDataModal.open();
let collapsibles = resultsList.metaDataModalContent.querySelectorAll(".text-metadata");
for (let collapsible of collapsibles) {
collapsible.onclick = () => {
let elems = resultsList.metaDataModalContent.querySelectorAll('.collapsible');
let instances = M.Collapsible.init(elems, {accordion: false});
resultsList.createTextDetails(results.metaData);
}
}
};
}
/**
* The following event listener handles the button showing infos about matches
* and their corresponding corpus files
*/
function showCorpusFiles(resultsList, results) {
resultsList.showCorpusFiles.onclick = () => {
resultsList.showCorpusFilesModalContent.innerHTML = '';
let htmlString = `
<div id="corpus-file-table">
<ul class="pagination paginationTop"></ul>
<table class="responsive-table highlight">
<thead>
<tr>
<th class="sort" data-sort="title">Title</th>
<th class="sort" data-sort="year">Year</th>
<th class="sort" data-sort="match-count">Match count in this text</th>
</tr>
</thead>
<tbody class="list">
`
for (let [key, value] of Object.entries(results.data.text_lookup)) {
htmlString += `
<tr>
<td class="title">${value.title}</td>
<td class="year">${value.publishing_year}</td>
<td class="match-count">${value.match_count}</td>
</tr>
`
}
htmlString += `
</tbody>
</table>
<ul class="pagination paginationBottom"></ul>
</div>
`
resultsList.showCorpusFilesModalContent.insertAdjacentHTML('afterbegin', htmlString);
resultsList.showCorpusFilesModal.open();
let options = {
page: 10,
pagination: [{
name: "paginationTop",
paginationClass: "paginationTop",
innerWindow: 8,
outerWindow: 1
}, {
paginationClass: "paginationBottom",
innerWindow: 8,
outerWindow: 1
}],
valueNames: ["title", "year", "match-count"],
};
let corpusFileTable = new List('corpus-file-table', options);
}
}
/**
* Checks if resultsList.exportFullInspectContext switch is changed.
* If it has been changed reset all Download buttons.
*/
function exportFullContextSwitch(resultsList) {
resultsList.exportFullInspectContext.onchange = (event) => {
// Hide all download buttons.
resultsList.fullResultsExport.classList.toggle('hide', true);
resultsList.subResultsExport.classList.toggle('hide', true);
// Show result create buttons.
resultsList.fullResultsCreate.classList.toggle('hide', false);
resultsList.subResultsCreate.classList.toggle('hide', false);
}
}
/**
* The following event listeners are handeling the data export.
* 1. Create full-results
* 2. Create sub-results
* 3. Download full-results
* 4. Download sub-results
* 5. Download single inspect-results
*/
// 1. Add events for full-results create
function createFullResults(resultsList, results) {
resultsList.fullResultsCreate.onclick = (event) => {
resultsList.fullResultsCreate.querySelector('i').classList.toggle('hide');
resultsList.fullResultsCreate.textContent = 'Creating...';
resultsList.fullResultsCreate.insertAdjacentHTML('afterbegin',
loadingSpinnerHTML);
// .keys() is for a zero based array. I think...
let dataIndexes = [...Array(results.data.match_count).keys()];
// Empty fullResultsData so that no previous data is used.
results.fullResultsData.init();
resultsList.notifyClient('get-results', {resultsType: 'full-results',
dataIndexes: dataIndexes,
resultsList: resultsList,});
}
}
// 2. Add events for sub-results create
function createSubResults(resultsList, results) {
resultsList.subResultsCreate.onclick = (event) => {
let dataIndexes = [];
Object.keys(resultsList.subResultsIndexes).forEach((id) => {
dataIndexes.push(id);
});
resultsList.subResultsCreate.querySelector('i').classList.toggle('hide');
resultsList.subResultsCreate.textContent = 'Creating...';
resultsList.subResultsCreate.insertAdjacentHTML('afterbegin',
loadingSpinnerHTML);
// Empty subResultsData so that no previous data is used.
results.subResultsData.init();
resultsList.notifyClient('get-results', {resultsType: 'sub-results',
dataIndexes: dataIndexes,
resultsList: resultsList,});
}
}
// 3. Open download modal when full results export button is pressed
function exportFullResults(resultsList, results) {
resultsList.fullResultsExport.onclick = (event) => {
resultsList.queryResultsDownloadModal.open();
// add onclick to download JSON button and download the file
resultsList.downloadResultsJson.onclick = (event) => {
let suffix = 'full-results'
if (resultsList.exportFullInspectContext.checked) {
suffix += '_full-context';
}
let filename = results.fullResultsData.createDownloadFilename(suffix);
results.fullResultsData.addData(results.metaData);
results.fullResultsData.downloadJSONRessource(filename,
results.fullResultsData,
resultsList.downloadResultsJson)};
}
}
// 4. Open download modal when sub results export button is pressed
function exportSubResults(resultsList, results) {
resultsList.subResultsExport.onclick = (event) => {
resultsList.queryResultsDownloadModal.open();
// add onclick to download JSON button and download the file
resultsList.downloadResultsJson.onclick = (event) => {
let suffix = 'sub-results'
if (resultsList.exportFullInspectContext.checked) {
suffix += '_full-context';
}
let filename = results.subResultsData.createDownloadFilename(suffix);
results.subResultsData.addData(results.metaData);
results.subResultsData.downloadJSONRessource(filename,
results.subResultsData,
resultsList.downloadResultsJson)};
}
}
// 5. Open download modal when inspect-results-export button is pressed
function exportSingleMatch(resultsList, results) {
resultsList.inspectResultsExport.onclick = (event) => {
resultsList.queryResultsDownloadModal.open();
// add onclick to download JSON button and download the file
resultsList.downloadResultsJson.onclick = (event) => {
let filename = results.subResultsData.createDownloadFilename('inspect-results_full-context');
results.subResultsData.addData(results.metaData);
results.subResultsData.downloadJSONRessource(filename,
results.inspectResultsData,
resultsList.downloadResultsJson)};
}
}
// export listeners
export {
recieveClientNotification,
pageNavigation,
expertModeSwitch,
actionButtons,
displayOptions,
showMetaData,
showCorpusFiles,
exportFullContextSwitch,
createFullResults,
createSubResults,
exportFullResults,
exportSubResults,
exportSingleMatch,
};

View File

@ -0,0 +1,27 @@
/**
* Function to show a scroll to top button if the user has scrolled down
* 250 pixels from the with scrollToElementSelector specified Element.
*/
function scrollToTop(scrollToElementSelector, triggerElementSelector) {
let scrollToThis = document.querySelector(scrollToElementSelector);
let scrolltoTopTrigger = document.querySelector(triggerElementSelector);
window.addEventListener('scroll', (event) => {
if (pageYOffset > 250) {
scrolltoTopTrigger.classList.toggle('hide', false);
} else {
scrolltoTopTrigger.classList.toggle('hide', true);
}
});
scrolltoTopTrigger.onclick = () => {
scrollToThis.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
};
}
// Export function.
export {
scrollToTop
};

View File

@ -0,0 +1,19 @@
// loading spinner animation HTML
const loadingSpinnerHTML = `
<div class="preloader-wrapper button-icon-spinner small 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>
`;
// Export const.
export {
loadingSpinnerHTML
};

View File

@ -0,0 +1,103 @@
class CorpusDisplay extends RessourceDisplay {
constructor(displayElement) {
super(displayElement);
this.corpusId = displayElement.dataset.corpusId;
this.user.eventListeners.corpus.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.corpusId);
}
init() {
for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {exportCorpusTriggerElement.addEventListener('click', () => this.requestCorpusExport());}
nopaque.appClient.socket.on(`export_corpus_${this.user.data.corpora[this.corpusId].id}`, () => this.downloadCorpus());
this.setCreationDate(this.user.data.corpora[this.corpusId].creation_date);
this.setDescription(this.user.data.corpora[this.corpusId].description);
this.setLastEditedDate(this.user.data.corpora[this.corpusId].last_edited_date);
this.setStatus(this.user.data.corpora[this.corpusId].status);
this.setTitle(this.user.data.corpora[this.corpusId].title);
this.setTokenRatio(this.user.data.corpora[this.corpusId].current_nr_of_tokens, this.user.data.corpora[this.corpusId].max_nr_of_tokens);
}
patch(patch) {
let re;
for (let operation of patch) {
switch(operation.op) {
case 'replace':
// Matches: /jobs/{this.job.id}/status
re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/last_edited_date');
if (re.test(operation.path)) {this.setLastEditedDate(operation.value); break;}
// Matches: /jobs/{this.job.id}/status
re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/status$');
if (re.test(operation.path)) {this.setStatus(operation.value); break;}
break;
default:
break;
}
}
}
requestCorpusExport() {
nopaque.appClient.socket.emit('export_corpus', this.user.data.corpora[this.corpusId].id);
nopaque.appClient.flash('Preparing your corpus export...', 'corpus');
for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {exportCorpusTriggerElement.classList.toggle('disabled', true);}
}
downloadCorpus() {
nopaque.appClient.flash('Corpus export is done. Your corpus download is ready!', 'corpus');
for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {exportCorpusTriggerElement.classList.toggle('disabled', false);}
// Little trick to call the download view after ziping has finished
let fakeBtn = document.createElement('a');
fakeBtn.href = `/corpora/${this.user.data.corpora[this.corpusId].id}/download`;
fakeBtn.click();
}
setTitle(title) {
for (let element of this.displayElement.querySelectorAll('.corpus-title')) {this.setElement(element, title);}
}
setTokenRatio(currentNrOfTokens, maxNrOfTokens) {
let tokenRatio = `${currentNrOfTokens}/${maxNrOfTokens}`;
for (let element of this.displayElement.querySelectorAll('.corpus-token-ratio')) {this.setElement(element, tokenRatio);}
}
setDescription(description) {
for (let element of this.displayElement.querySelectorAll('.corpus-description')) {this.setElement(element, description);}
}
setStatus(status) {
for (let element of this.displayElement.querySelectorAll('.analyse-corpus-trigger')) {
if (['analysing', 'prepared', 'start analysis'].includes(status)) {
element.classList.remove('disabled');
} else {
element.classList.add('disabled');
}
}
for (let element of this.displayElement.querySelectorAll('.build-corpus-trigger')) {
if (status === 'unprepared' && Object.values(this.user.data.corpora[this.corpusId].files).length > 0) {
element.classList.remove('disabled');
} else {
element.classList.add('disabled');
}
}
for (let element of this.displayElement.querySelectorAll('.corpus-status')) {this.setElement(element, status);}
for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {
exportCorpusTriggerElement.classList.toggle('disabled', !['prepared', 'start analysis', 'stop analysis'].includes(status));
}
for (let element of this.displayElement.querySelectorAll('.status')) {element.dataset.status = status;}
for (let element of this.displayElement.querySelectorAll('.status-spinner')) {
if (['submitted', 'queued', 'running', 'canceling', 'start analysis', 'stop analysis'].includes(status)) {
element.classList.remove('hide');
} else {
element.classList.add('hide');
}
}
}
setCreationDate(creationDateTimestamp) {
let creationDate = new Date(creationDateTimestamp * 1000).toLocaleString("en-US");
for (let element of this.displayElement.querySelectorAll('.corpus-creation-date')) {this.setElement(element, creationDate);}
}
setLastEditedDate(endDateTimestamp) {
let endDate = new Date(endDateTimestamp * 1000).toLocaleString("en-US");
for (let element of this.displayElement.querySelectorAll('.corpus-end-date')) {this.setElement(element, endDate);}
}
}

View File

@ -0,0 +1,87 @@
class JobDisplay extends RessourceDisplay {
constructor(displayElement) {
super(displayElement);
this.jobId = displayElement.dataset.jobId;
this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.jobId);
}
init(job) {
this.setCreationDate(this.user.data.jobs[this.jobId].creation_date);
this.setEndDate(this.user.data.jobs[this.jobId].creation_date);
this.setDescription(this.user.data.jobs[this.jobId].description);
this.setService(this.user.data.jobs[this.jobId].service);
this.setServiceArgs(this.user.data.jobs[this.jobId].service_args);
this.setServiceVersion(this.user.data.jobs[this.jobId].service_version);
this.setStatus(this.user.data.jobs[this.jobId].status);
this.setTitle(this.user.data.jobs[this.jobId].title);
}
patch(patch) {
let re;
for (let operation of patch) {
switch(operation.op) {
case 'replace':
// Matches: /jobs/{this.user.data.jobs[this.jobId].id}/status
re = new RegExp('^/jobs/' + this.user.data.jobs[this.jobId].id + '/end_date');
if (re.test(operation.path)) {this.setEndDate(operation.value); break;}
// Matches: /jobs/{this.user.data.jobs[this.jobId].id}/status
re = new RegExp('^/jobs/' + this.user.data.jobs[this.jobId].id + '/status$');
if (re.test(operation.path)) {this.setStatus(operation.value); break;}
break;
default:
break;
}
}
}
setTitle(title) {
for (let element of this.displayElement.querySelectorAll('.job-title')) {this.setElement(element, title);}
}
setDescription(description) {
for (let element of this.displayElement.querySelectorAll('.job-description')) {this.setElement(element, description);}
}
setStatus(status) {
for (let element of this.displayElement.querySelectorAll('.job-status')) {
this.setElement(element, status);
}
for (let element of this.displayElement.querySelectorAll('.status')) {element.dataset.status = status;}
for (let element of this.displayElement.querySelectorAll('.status-spinner')) {
if (['complete', 'failed'].includes(status)) {
element.classList.add('hide');
} else {
element.classList.remove('hide');
}
}
for (let element of this.displayElement.querySelectorAll('.restart-job-trigger')) {
if (['complete', 'failed'].includes(status)) {
element.classList.remove('hide');
} else {
element.classList.add('hide');
}
}
}
setCreationDate(creationDateTimestamp) {
let creationDate = new Date(creationDateTimestamp * 1000).toLocaleString("en-US");
for (let element of this.displayElement.querySelectorAll('.job-creation-date')) {this.setElement(element, creationDate);}
}
setEndDate(endDateTimestamp) {
let endDate = new Date(endDateTimestamp * 1000).toLocaleString("en-US");
for (let element of this.displayElement.querySelectorAll('.job-end-date')) {this.setElement(element, endDate);}
}
setService(service) {
for (let element of this.displayElement.querySelectorAll('.job-service')) {this.setElement(element, service);}
}
setServiceArgs(serviceArgs) {
for (let element of this.displayElement.querySelectorAll('.job-service-args')) {this.setElement(element, serviceArgs);}
}
setServiceVersion(serviceVersion) {
for (let element of this.displayElement.querySelectorAll('.job-service-version')) {this.setElement(element, serviceVersion);}
}
}

View File

@ -0,0 +1,45 @@
class RessourceDisplay {
constructor(displayElement) {
if (displayElement.dataset.userId) {
if (displayElement.dataset.userId in nopaque.appClient.users) {
this.user = nopaque.appClient.users[displayElement.dataset.userId];
} else {
console.error(`User not found: ${displayElement.dataset.userId}`);
return;
}
} else {
this.user = nopaque.appClient.users.self;
}
this.displayElement = displayElement;
}
eventHandler(eventType, payload) {
switch (eventType) {
case 'init':
this.init(payload);
break;
case 'patch':
this.patch(payload);
break;
default:
console.error(`Unknown event type: ${eventType}`);
break;
}
}
init() {console.error('init method not implemented!');}
patch() {console.error('patch method not implemented!');}
setElement(element, value) {
switch (element.tagName) {
case 'INPUT':
element.value = value;
M.updateTextFields();
break;
default:
element.innerText = value;
break;
}
}
}

View File

@ -0,0 +1,97 @@
class CorpusFileList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...CorpusFileList.options, ...options});
this.corpusId = listElement.dataset.corpusId;
this.user.eventListeners.corpus.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.corpusId);
}
init() {
super.init(this.user.data.corpora[this.corpusId].files);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let corpusFileId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
if (actionButtonElement === null) {return;}
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
switch (action) {
case 'delete':
let deleteModalHTML = `<div class="modal">
<div class="modal-content">
<h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus file <b>${this.user.data.corpora[this.corpusId].files[corpusFileId].filename}</b>? It will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="${this.user.data.corpora[this.corpusId].files[corpusFileId].url}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>`;
let deleteModalParentElement = document.querySelector('#modals');
deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
let deleteModalElement = deleteModalParentElement.lastChild;
let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
deleteModal.open();
break;
case 'download':
window.location.href = this.user.data.corpora[this.corpusId].files[corpusFileId].download_url;
break;
case 'view':
if (corpusFileId !== '-1') {window.location.href = this.user.data.corpora[this.corpusId].files[corpusFileId].url;}
break;
default:
console.error(`Unknown action: "${action}"`);
break;
}
}
patch(patch) {
let id, match, re, valueName;
for (let operation of patch) {
switch(operation.op) {
case 'add':
// Matches the only paths that should be handled here: /corpora/{this.user.data.corpora[this.corpusId].id}/files/{corpusFileId}
re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)$');
if (re.test(operation.path)) {this.add(operation.value);}
break;
case 'remove':
// See case add ;)
re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)$');
if (re.test(operation.path)) {
[match, id] = operation.path.match(re);
this.remove(id);
}
break;
case 'replace':
// Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title}
re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)/(author|filename|publishing_year|title)$');
if (re.test(operation.path)) {
[match, id, valueName] = operation.path.match(re);
this.replace(id, valueName, operation.value);
}
break;
default:
break;
}
}
}
preprocessRessource(corpusFile) {
return {id: corpusFile.id, author: corpusFile.author, filename: corpusFile.filename, publishing_year: corpusFile.publishing_year, title: corpusFile.title};
}
}
CorpusFileList.options = {
item: `<tr>
<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 tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, 'author', 'filename', 'publishing_year', 'title']
};

View File

@ -0,0 +1,93 @@
class CorpusList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...CorpusList.options, ...options});
this.user.eventListeners.corpus.addEventListener((eventType, payload) => this.eventHandler(eventType, payload));
}
init() {
super.init(this.user.data.corpora);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let corpusId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
let action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action;
switch (action) {
case 'delete':
let deleteModalHTML = `<div class="modal">
<div class="modal-content">
<h4>Confirm corpus deletion</h4>
<p>Do you really want to delete the corpus <b>${this.user.data.corpora[corpusId].title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="${this.user.data.corpora[corpusId].url}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>`;
let deleteModalParentElement = document.querySelector('#modals');
deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
let deleteModalElement = deleteModalParentElement.lastChild;
let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
deleteModal.open();
break;
case 'view':
if (corpusId !== '-1') {window.location.href = this.user.data.corpora[corpusId].url;}
break;
default:
console.error(`Unknown action: ${action}`);
break;
}
}
patch(patch) {
let id, match, re, valueName;
for (let operation of patch) {
switch(operation.op) {
case 'add':
// Matches the only paths that should be handled here: /corpora/{corpusId}
re = /^\/corpora\/(\d+)$/;
if (re.test(operation.path)) {this.add(operation.value);}
break;
case 'remove':
// See case 'add' ;)
re = /^\/corpora\/(\d+)$/;
if (re.test(operation.path)) {
[match, id] = operation.path.match(re);
this.remove(id);
}
break;
case 'replace':
// Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title}
re = /^\/corpora\/(\d+)\/(status|description|title)$/;
if (re.test(operation.path)) {
[match, id, valueName] = operation.path.match(re);
this.replace(id, valueName, operation.value);
}
break;
default:
break;
}
}
}
preprocessRessource(corpus) {
return {id: corpus.id,
status: corpus.status,
description: corpus.description,
title: corpus.title};
}
}
CorpusList.options = {
item: `<tr>
<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="badge new status status-color status-text" data-badge-caption=""></span></td>
<td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, {name: 'status', attr: 'data-status'}, 'description', 'title']
};

View File

@ -0,0 +1,41 @@
class JobInputList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...JobInputList.options, ...options});
this.jobId = listElement.dataset.jobId;
this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.jobId);
}
init() {
super.init(this.user.data.jobs[this.jobId].inputs);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let jobInputId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
if (actionButtonElement === null) {return;}
let action = actionButtonElement.dataset.action;
switch (action) {
case 'download':
window.location.href = this.user.data.jobs[this.jobId].inputs[jobInputId].download_url;
break;
default:
console.error(`Unknown action: "${action}"`);
break;
}
}
preprocessRessource(jobInput) {
return {id: jobInput.id, filename: jobInput.filename};
}
}
JobInputList.options = {
item: `<tr>
<td><span class="filename"></span></td>
<td class="right-align">
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, 'filename']
};

View File

@ -0,0 +1,94 @@
class JobList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...JobList.options, ...options});
this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload));
}
init() {
super.init(this.user.data.jobs);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let jobId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
switch (action) {
case 'delete':
let deleteModalHTML = `<div class="modal">
<div class="modal-content">
<h4>Confirm job deletion</h4>
<p>Do you really want to delete the job <b>${this.user.data.jobs[jobId].title}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="${this.user.data.jobs[jobId].url}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>`;
let deleteModalParentElement = document.querySelector('#modals');
deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
let deleteModalElement = deleteModalParentElement.lastChild;
let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
deleteModal.open();
break;
case 'view':
if (jobId !== '-1') {window.location.href = this.user.data.jobs[jobId].url;}
break;
default:
console.error(`Unknown action: "${action}"`);
break;
}
}
patch(patch) {
let id, match, re, valueName;
for (let operation of patch) {
switch(operation.op) {
case 'add':
// Matches the only paths that should be handled here: /jobs/{jobId}
re = /^\/jobs\/(\d+)$/;
if (re.test(operation.path)) {this.add(operation.value);}
break;
case 'remove':
// See case add ;)
re = /^\/jobs\/(\d+)$/;
if (re.test(operation.path)) {
[match, id] = operation.path.match(re);
this.remove(id);
}
break;
case 'replace':
// Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title}
re = /^\/jobs\/(\d+)\/(service|status|description|title)$/;
if (re.test(operation.path)) {
[match, id, valueName] = operation.path.match(re);
this.replace(id, valueName, operation.value);
}
break;
default:
break;
}
}
}
preprocessRessource(job) {
return {id: job.id,
service: job.service,
status: job.status,
description: job.description,
title: job.title};
}
}
JobList.options = {
item: `<tr>
<td><a class="btn-floating disabled"><i class="nopaque-icons service service-color darken service-icon"></i></a></td>
<td><b class="title"></b><br><i class="description"></i></td>
<td><span class="badge new status status-color status-text" data-badge-caption=""></span></td>
<td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: 'status', attr: 'data-status'}, 'description', 'title']
};

View File

@ -0,0 +1,71 @@
class JobResultList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...JobResultList.options, ...options});
this.jobId = listElement.dataset.jobId;
this.user.eventListeners.job.addEventListener((eventType, payload) => this.eventHandler(eventType, payload), this.jobId);
}
init() {
super.init(this.user.data.jobs[this.jobId].results);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let jobResultId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
if (actionButtonElement === null) {return;}
let action = actionButtonElement.dataset.action;
switch (action) {
case 'download':
window.location.href = this.user.data.jobs[this.jobId].results[jobResultId].download_url;
break;
default:
console.error(`Unknown action: "${action}"`);
break;
}
}
patch(patch) {
let re;
for (let operation of patch) {
switch(operation.op) {
case 'add':
// Matches the only paths that should be handled here: /jobs/{this.user.data.jobs[this.jobId].id}/results/{jobResultId}
re = new RegExp('^/jobs/' + this.user.data.jobs[this.jobId].id + '/results/(\\d+)$');
if (re.test(operation.path)) {this.add(operation.value);}
break;
default:
break;
}
}
}
preprocessRessource(jobResult) {
let description;
if (jobResult.filename.endsWith('.pdf.zip')) {
description = 'PDF files with text layer';
} else if (jobResult.filename.endsWith('.txt.zip')) {
description = 'Raw text files';
} else if (jobResult.filename.endsWith('.vrt.zip')) {
description = 'VRT compliant files including the NLP data';
} else if (jobResult.filename.endsWith('.xml.zip')) {
description = 'TEI compliant files';
} else if (jobResult.filename.endsWith('.poco.zip')) {
description = 'HOCR and image files for post correction (PoCo)';
} else {
description = 'All result files created during this job';
}
return {id: jobResult.id, description: description, filename: jobResult.filename};
}
}
JobResultList.options = {
item: `<tr>
<td><span class="description"></span></td>
<td><span class="filename"></span></td>
<td class="right-align">
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="download" data-position="top" data-tooltip="View"><i class="material-icons">file_download</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, 'description', 'filename']
};

View File

@ -0,0 +1,93 @@
class QueryResultList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...QueryResultList.options, ...options});
this.user.eventListeners.queryResult.addEventListener((eventType, payload) => this.eventHandler(eventType, payload));
}
init() {
super.init(this.user.data.query_results);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let queryResultId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
switch (action) {
case 'delete':
let deleteModalHTML = `<div class="modal">
<div class="modal-content">
<h4>Confirm query result deletion</h4>
<p>Do you really want to delete the query result <b>${this.user.data.query_results[queryResultId].title}</b>? It will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="${this.user.data.query_results[queryResultId].url}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>`;
let deleteModalParentElement = document.querySelector('#modals');
deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
let deleteModalElement = deleteModalParentElement.lastChild;
let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
deleteModal.open();
break;
case 'view':
if (queryResultId !== '-1') {window.location.href = this.user.data.query_results[queryResultId].url;}
break;
default:
console.error(`Unknown action: "${action}"`);
break;
}
}
patch(patch) {
let id, match, re, valueName;
for (let operation of patch) {
switch(operation.op) {
case 'add':
// Matches the only paths that should be handled here: /jobs/{jobId}
re = /^\/query_results\/(\d+)$/;
if (re.test(operation.path)) {this.add(operation.value);}
break;
case 'remove':
// See case add ;)
re = /^\/query_results\/(\d+)$/;
if (re.test(operation.path)) {
[match, id] = operation.path.match(re);
this.remove(id);
}
break;
case 'replace':
// Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title}
re = /^\/query_results\/(\d+)\/(corpus_title|description|query|title)$/;
if (re.test(operation.path)) {
[match, id, valueName] = operation.path.match(re);
this.replace(id, valueName, operation.value);
}
break;
default:
break;
}
}
}
preprocessRessource(queryResult) {
return {id: queryResult.id,
corpus_title: queryResult.corpus_title,
description: queryResult.description,
query: queryResult.query,
title: queryResult.title};
}
}
QueryResultList.options = {
item: `<tr>
<td><b class="title"></b><br><i class="description"></i><br></td>
<td><span class="corpus_title"></span><br><span class="query"></span></td>
<td class="right-align">
<a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, 'corpus_title', 'description', 'query', 'title']
};

View File

@ -0,0 +1,99 @@
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.
*/
constructor(listElement, options = {}) {
if (listElement.dataset.userId) {
if (listElement.dataset.userId in nopaque.appClient.users) {
this.user = nopaque.appClient.users[listElement.dataset.userId];
} else {
console.error(`User not found: ${listElement.dataset.userId}`);
return;
}
} else {
this.user = nopaque.appClient.users.self;
}
this.list = new List(listElement, {...RessourceList.options, ...options});
this.list.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>`;
this.list.list.style.cursor = 'pointer';
if (typeof this.onclick === 'function') {this.list.list.addEventListener('click', event => this.onclick(event));}
}
eventHandler(eventType, payload) {
switch (eventType) {
case 'init':
this.init();
break;
case 'patch':
this.patch(payload);
break;
default:
console.error(`Unknown event type: ${eventType}`);
break;
}
}
init(ressources) {
this.list.clear();
this.add(Object.values(ressources));
this.list.sort('id', {order: 'desc'});
let emptyListElementHTML = `<tr class="show-if-only-child" data-id="-1">
<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>`;
this.list.list.insertAdjacentHTML('afterbegin', emptyListElementHTML);
}
patch(patch) {
/*
* It's not possible to generalize a patch Handler for all type of
* ressources. So this method is meant to be an interface.
*/
console.error('patch method not implemented!');
}
add(values) {
let ressources = Array.isArray(values) ? values : [values];
if (typeof this.preprocessRessource === 'function') {
ressources = ressources.map(ressource => this.preprocessRessource(ressource));
}
// Set a callback function ('() => {return;}') to force List.js perform the
// add method asynchronous: https://listjs.com/api/#add
this.list.add(ressources, () => {return;});
}
remove(id) {
this.list.remove('id', id);
}
replace(id, valueName, newValue) {
this.list.get('id', id)[0].values({[valueName]: newValue});
}
}
RessourceList.options = {page: 5, pagination: [{innerWindow: 4, outerWindow: 1}]};

View File

@ -0,0 +1,71 @@
class UserList extends RessourceList {
constructor(listElement, options = {}) {
super(listElement, {...UserList.options, ...options});
users = undefined;
}
init(users) {
this.users = users;
super.init(users);
}
onclick(event) {
let ressourceElement = event.target.closest('tr');
if (ressourceElement === null) {return;}
let userId = ressourceElement.dataset.id;
let actionButtonElement = event.target.closest('.action-button');
let action = (actionButtonElement === null) ? 'view' : actionButtonElement.dataset.action;
switch (action) {
case 'delete':
let deleteModalHTML = `<div class="modal">
<div class="modal-content">
<h4>Confirm user deletion</h4>
<p>Do you really want to delete the corpus <b>${this.users[userId].username}</b>? All files will be permanently deleted!</p>
</div>
<div class="modal-footer">
<a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
<a class="btn modal-close red waves-effect waves-light" href="/admin/users/${userId}/delete"><i class="material-icons left">delete</i>Delete</a>
</div>
</div>`;
let deleteModalParentElement = document.querySelector('#modals');
deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
let deleteModalElement = deleteModalParentElement.lastChild;
let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
deleteModal.open();
break;
case 'edit':
window.location.href = `/admin/users/${userId}/edit`;
break;
case 'view':
if (userId !== '-1') {window.location.href = `/admin/users/${userId}`;}
break;
default:
console.error(`Unknown action: ${action}`);
break;
}
}
preprocessRessource(user) {
return {id: user.id,
id_: user.id,
username: user.username,
email: user.email,
last_seen: new Date(user.last_seen * 1000).toLocaleString("en-US"),
role: user.role.name};
}
}
UserList.options = {
item: `<tr>
<td><span class="id_"></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 tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="edit" data-position="top" data-tooltip="Edit"><i class="material-icons">edit</i></a>
<a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
</td>
</tr>`,
valueNames: [{data: ['id']}, 'id_', 'username', 'email', 'last_seen', 'role']
};

View File

@ -0,0 +1,250 @@
class AppClient {
constructor(currentUserId) {
if (currentUserId) {
this.socket = io({transports: ['websocket']});
this.users = {};
this.users.self = this.loadUser(currentUserId);
this.users.self.eventListeners.job.addEventListener((eventType, payload) => this.jobEventHandler(eventType, payload));
}
}
flash(message, category) {
let toast;
let toastCloseActionElement;
switch (category) {
case "corpus":
message = `<i class="left material-icons">book</i>${message}`;
break;
case "error":
message = `<i class="left material-icons error-color-text">error</i>${message}`;
break;
case "job":
message = `<i class="left nopaque-icons">J</i>${message}`;
break;
default:
message = `<i class="left material-icons">notifications</i>${message}`;
}
toast = M.toast({html: `<span>${message}</span>
<button data-action="close" class="btn-flat toast-action white-text">
<i class="material-icons">close</i>
</button>`});
toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
toastCloseActionElement.addEventListener('click', () => {toast.dismiss();});
}
jobEventHandler(eventType, payload) {
switch (eventType) {
case 'init':
break;
case 'patch':
this.jobPatch(payload);
break;
default:
console.error(`[AppClient.jobEventHandler] Unknown event type: ${eventType}`);
break;
}
}
loadUser(userId) {
if (userId in this.users) {return this.users[userId];}
let user = new User();
this.users[userId] = user;
this.socket.on(`user_${userId}_init`, msg => user.init(msg));
this.socket.on(`user_${userId}_patch`, msg => user.patch(msg));
this.socket.emit('start_user_session', userId);
return user;
}
jobPatch(patch) {
if (this.users.self.data.settings.job_status_site_notifications === 'none') {return;}
let jobStatusPatches = patch.filter(operation => operation.op === 'replace' && /^\/jobs\/(\d+)\/status$/.test(operation.path));
for (let operation of jobStatusPatches) {
let [match, jobId] = operation.path.match(/^\/jobs\/(\d+)\/status$/);
if (this.users.self.data.settings.job_status_site_notifications === "end" && !['complete', 'failed'].includes(operation.value)) {continue;}
this.flash(`[<a href="/jobs/${jobId}">${this.users.self.data.jobs[jobId].title}</a>] New status: ${operation.value}`, 'job');
}
}
}
class User {
constructor() {
this.data = undefined;
this.eventListeners = {
corpus: {
addEventListener(listener, corpusId='*') {
if (corpusId in this) {this[corpusId].push(listener);} else {this[corpusId] = [listener];}
}
},
job: {
addEventListener(listener, jobId='*') {
if (jobId in this) {this[jobId].push(listener);} else {this[jobId] = [listener];}
}
},
queryResult: {
addEventListener(listener, queryResultId='*') {
if (queryResultId in this) {this[queryResultId].push(listener);} else {this[queryResultId] = [listener];}
}
}
};
}
init(data) {
this.data = data;
for (let [corpusId, eventListeners] of Object.entries(this.eventListeners.corpus)) {
if (corpusId === '*') {
for (let eventListener of eventListeners) {eventListener('init');}
} else {
if (corpusId in this.data.corpora) {
for (let eventListener of eventListeners) {eventListener('init');}
}
}
}
for (let [jobId, eventListeners] of Object.entries(this.eventListeners.job)) {
if (jobId === '*') {
for (let eventListener of eventListeners) {eventListener('init');}
} else {
if (jobId in this.data.jobs) {
for (let eventListener of eventListeners) {eventListener('init');}
}
}
}
for (let [queryResultId, eventListeners] of Object.entries(this.eventListeners.queryResult)) {
if (queryResultId === '*') {
for (let eventListener of eventListeners) {eventListener('init');}
} else {
if (queryResultId in this.data.query_results) {
for (let eventListener of eventListeners) {eventListener('init');}
}
}
}
}
patch(patch) {
this.data = jsonpatch.apply_patch(this.data, patch);
let corporaPatch = patch.filter(operation => operation.path.startsWith("/corpora"));
if (corporaPatch.length > 0) {
for (let [corpusId, eventListeners] of Object.entries(this.eventListeners.corpus)) {
if (corpusId === '*') {
for (let eventListener of eventListeners) {eventListener('patch', corporaPatch);}
} else {
let corpusPatch = corporaPatch.filter(operation => operation.path.startsWith(`/corpora/${corpusId}`));
if (corpusPatch.length > 0) {
for (let eventListener of eventListeners) {eventListener('patch', corpusPatch);}
}
}
}
}
let jobsPatch = patch.filter(operation => operation.path.startsWith("/jobs"));
if (jobsPatch.length > 0) {
for (let [jobId, eventListeners] of Object.entries(this.eventListeners.job)) {
if (jobId === '*') {
for (let eventListener of eventListeners) {eventListener('patch', jobsPatch);}
} else {
let jobPatch = jobsPatch.filter(operation => operation.path.startsWith(`/jobs/${jobId}`));
if (jobPatch.length > 0) {
for (let eventListener of eventListeners) {eventListener('patch', jobPatch);}
}
}
}
}
let queryResultsPatch = patch.filter(operation => operation.path.startsWith("/query_results"));
if (queryResultsPatch.length > 0) {
for (let [queryResultId, eventListeners] of Object.entries(this.eventListeners.queryResult)) {
if (queryResultId === '*') {
for (let eventListener of eventListeners) {eventListener('patch', queryResultsPatch);}
} else {
let queryResultPatch = queryResultsPatch.filter(operation => operation.path.startsWith(`/query_results/${queryResultId}`));
if (queryResultPatch.length > 0) {
for (let eventListener of eventListeners) {eventListener('patch', queryResultPatch);}
}
}
}
}
}
}
/*
* The nopaque object is used as a namespace for nopaque specific functions and
* variables.
*/
var nopaque = {};
nopaque.Forms = {};
nopaque.Forms.init = function() {
var abortRequestElement, parentElement, progressElement, progressModal,
progressModalElement, request, submitElement;
for (let form of document.querySelectorAll(".nopaque-submit-form")) {
submitElement = form.querySelector('button[type="submit"]');
submitElement.addEventListener("click", function() {
for (let selectElement of form.querySelectorAll('select')) {
if (selectElement.value === "") {
parentElement = selectElement.closest(".input-field");
parentElement.querySelector(".select-dropdown").classList.add("invalid");
for (let helperTextElement of parentElement.querySelectorAll(".helper-text")) {
helperTextElement.remove();
}
parentElement.insertAdjacentHTML("beforeend", `<span class="helper-text red-text">Please select an option.</span>`);
}
}
});
request = new XMLHttpRequest();
if (form.dataset.hasOwnProperty("progressModal")) {
progressModalElement = document.getElementById(form.dataset.progressModal);
progressModal = M.Modal.getInstance(progressModalElement);
progressModal.options.dismissible = false;
abortRequestElement = progressModalElement.querySelector(".abort-request");
abortRequestElement.addEventListener("click", function() {request.abort();});
progressElement = progressModalElement.querySelector(".determinate");
}
form.addEventListener("submit", function(event) {
event.preventDefault();
var formData;
formData = new FormData(form);
// Initialize progress modal
if (progressModalElement) {
progressElement.style.width = "0%";
progressModal.open();
}
request.open("POST", window.location.href);
request.send(formData);
});
request.addEventListener("load", function(event) {
var fieldElement;
if (request.status === 201) {
window.location.href = JSON.parse(this.responseText).redirect_url;
}
if (request.status === 400) {
for (let [field, errors] of Object.entries(JSON.parse(this.responseText))) {
fieldElement = form.querySelector(`input[name$="${field}"]`).closest(".input-field");
for (let error of errors) {
fieldElement.insertAdjacentHTML("beforeend", `<span class="helper-text red-text">${error}</span>`);
}
}
if (progressModalElement) {
progressModal.close();
}
}
if (request.status === 500) {
location.reload();
}
});
if (progressModalElement) {
request.upload.addEventListener("progress", function(event) {
progressElement.style.width = Math.floor(100 * event.loaded / event.total).toString() + "%";
});
}
}
}

7
app/static/js/socket.io.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long