diff --git a/web/app/events.py b/web/app/events.py
index df716d81..a0f76b3b 100644
--- a/web/app/events.py
+++ b/web/app/events.py
@@ -33,38 +33,24 @@ def disconnect():
connected_sessions.remove(request.sid)
-@socketio.on('user_data_stream_init')
+@socketio.on('start_user_session')
@socketio_login_required
-def user_data_stream_init():
- socketio.start_background_task(user_data_stream,
+def start_user_session(user_id):
+ if not (current_user.id == user_id or current_user.is_administrator):
+ return
+ socketio.start_background_task(user_session,
current_app._get_current_object(),
- current_user.id, request.sid)
+ user_id, request.sid)
-@socketio.on('foreign_user_data_stream_init')
-@socketio_login_required
-@socketio_admin_required
-def foreign_user_data_stream_init(user_id):
- socketio.start_background_task(user_data_stream,
- current_app._get_current_object(),
- user_id, request.sid, foreign=True)
-
-
-def user_data_stream(app, user_id, session_id, foreign=False):
+def user_session(app, user_id, session_id):
'''
- ' Sends initial corpus and job lists to the client. Afterwards it checks
- ' every 3 seconds if changes to the initial values appeared. If changes are
- ' detected, a RFC 6902 compliant JSON patch gets send.
- '
- ' NOTE: The initial values are send as a init events.
- ' The JSON patches are send as update events.
+ ' Sends initial user data to the client. Afterwards it checks every 3s if
+ ' changes to the initial values appeared. If changes are detected, a
+ ' RFC 6902 compliant JSON patch gets send.
'''
- if foreign:
- init_event = 'foreign_user_data_stream_init'
- update_event = 'foreign_user_data_stream_update'
- else:
- init_event = 'user_data_stream_init'
- update_event = 'user_data_stream_update'
+ init_event = 'user_{}_init'.format(user_id)
+ patch_event = 'user_{}_patch'.format(user_id)
with app.app_context():
# Gather current values from database.
user = User.query.get(user_id)
@@ -80,7 +66,7 @@ def user_data_stream(app, user_id, session_id, foreign=False):
new_user_dict)
# In case there are patches, send them to the client.
if user_patch:
- socketio.emit(update_event, user_patch.to_string(),
+ socketio.emit(patch_event, user_patch.to_string(),
room=session_id)
# Set new values as references for the next iteration.
user_dict = new_user_dict
diff --git a/web/app/static/css/nopaque.css b/web/app/static/css/nopaque.css
index 3ad3a914..597701aa 100644
--- a/web/app/static/css/nopaque.css
+++ b/web/app/static/css/nopaque.css
@@ -8,6 +8,10 @@ main {
margin-top: 48px;
}
+table.ressource-list tr {
+ cursor: pointer;
+}
+
.parallax-container .parallax {
z-index: auto;
}
diff --git a/web/app/static/js/nopaque.js b/web/app/static/js/nopaque.js
index 5e3ad92a..bd5e3f4e 100644
--- a/web/app/static/js/nopaque.js
+++ b/web/app/static/js/nopaque.js
@@ -1,96 +1,138 @@
+class AppClient {
+ constructor(currentUserId) {
+ this.socket = io({transports: ['websocket']});
+ this.users = {};
+ this.users.self = this.loadUser(currentUserId);
+ }
+
+ loadUser(userId) {
+ let user = new User();
+ this.users[userId] = user;
+ this.socket.on(`user_${userId}_init`, msg => user.init(JSON.parse(msg)));
+ this.socket.on(`user_${userId}_patch`, msg => user.patch(JSON.parse(msg)));
+ this.socket.emit('start_user_session', userId);
+ return user;
+ }
+}
+
+
+class User {
+ constructor() {
+ this.data = undefined;
+ this.eventListeners = {
+ corporaInit: [],
+ corporaPatch: [],
+ jobsInit: [],
+ jobsPatch: [],
+ queryResultsInit: [],
+ queryResultsPatch: []
+ };
+ }
+
+ init(data) {
+ this.data = data;
+
+ let listener;
+ for (listener of this.eventListeners.corporaInit) {
+ listener(this.data.corpora);
+ }
+ for (listener of this.eventListeners.jobsInit) {
+ listener(this.data.jobs);
+ }
+ for (listener of this.eventListeners.queryResultsInit) {
+ listener(this.data.query_results);
+ }
+ }
+
+ patch(patch) {
+ this.data = jsonpatch.apply_patch(this.data, patch);
+
+ let corporaPatch = patch.filter(operation => operation.path.startsWith("/corpora"));
+ let jobsPatch = patch.filter(operation => operation.path.startsWith("/jobs"));
+ let queryResultsPatch = patch.filter(operation => operation.path.startsWith("/query_results"));
+
+ for (let listener of this.eventListeners.corporaPatch) {
+ if (corporaPatch.length > 0) {listener(corporaPatch);}
+ }
+ for (let listener of this.eventListeners.jobsPatch) {
+ if (jobsPatch.length > 0) {listener(jobsPatch);}
+ }
+ for (let listener of this.eventListeners.queryResultsPatch) {
+ if (queryResultsPatch.length > 0) {listener(queryResultsPatch);}
+ }
+
+ for (let operation of jobsPatch) {
+ if (operation.op !== 'replace') {continue;}
+ // Matches the only path that should be handled here: /jobs/{jobId}/status
+ if (/^\/jobs\/(\d+)\/status$/.test(operation.path)) {
+ let [match, jobId] = operation.path.match(/^\/jobs\/(\d+)\/status$/);
+ if (this.data.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;}
+ nopaque.flash(`[${this.data.jobs[jobId].title}] New status: ${operation.value}`, "job");
+ }
+ }
+ }
+
+ addEventListener(type, listener) {
+ switch (type) {
+ case 'corporaInit':
+ this.eventListeners.corporaInit.push(listener);
+ if (this.data !== undefined) {listener(this.data.corpora);}
+ break;
+ case 'corporaPatch':
+ this.eventListeners.corporaPatch.push(listener);
+ break;
+ case 'jobsInit':
+ this.eventListeners.jobsInit.push(listener);
+ if (this.data !== undefined) {listener(this.data.jobs);}
+ break;
+ case 'jobsPatch':
+ this.eventListeners.jobsPatch.push(listener);
+ break;
+ case 'queryResultsInit':
+ this.eventListeners.queryResultsInit.push(listener);
+ if (this.data !== undefined) {listener(this.data.query_results);}
+ break;
+ case 'queryResultsPatch':
+ this.eventListeners.queryResultsPatch.push(listener);
+ break;
+ default:
+ console.error(`Unknown event type: ${type}`);
+ }
+ }
+}
+
+
/*
* The nopaque object is used as a namespace for nopaque specific functions and
* variables.
*/
var nopaque = {};
-// User data
-nopaque.user = {};
-nopaque.user.settings = {};
-nopaque.user.settings.darkMode = undefined;
-nopaque.corporaSubscribers = [];
-nopaque.jobsSubscribers = [];
-nopaque.queryResultsSubscribers = [];
+nopaque.flash = function(message, category) {
+ let toast;
+ let toastActionElement;
-// Foreign user (user inspected with admin credentials) data
-nopaque.foreignUser = {};
-nopaque.foreignUser.isAuthenticated = undefined;
-nopaque.foreignUser.settings = {};
-nopaque.foreignUser.settings.darkMode = undefined;
-nopaque.foreignCorporaSubscribers = [];
-nopaque.foreignJobsSubscribers = [];
-nopaque.foreignQueryResultsSubscribers = [];
+ switch (category) {
+ case "corpus":
+ message = `book${message}`;
+ break;
+ case "error":
+ message = `error${message}`;
+ break;
+ case "job":
+ message = `work${message}`;
+ break;
+ default:
+ message = `notifications${message}`;
+ }
-// nopaque functions
-nopaque.socket = io({transports: ['websocket']});
-// Add event handlers
-nopaque.socket.on("user_data_stream_init", function(msg) {
- nopaque.user = JSON.parse(msg);
- for (let subscriber of nopaque.corporaSubscribers) {
- subscriber.init(nopaque.user.corpora);
- }
- for (let subscriber of nopaque.jobsSubscribers) {
- subscriber.init(nopaque.user.jobs);
- }
- for (let subscriber of nopaque.queryResultsSubscribers) {
- subscriber.init(nopaque.user.query_results);
- }
-});
-
-nopaque.socket.on("user_data_stream_update", function(msg) {
- var patch;
-
- patch = JSON.parse(msg);
- nopaque.user = jsonpatch.apply_patch(nopaque.user, patch);
- corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora"));
- jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs"));
- query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results"));
- for (let subscriber of nopaque.corporaSubscribers) {
- subscriber.update(corpora_patch);
- }
- for (let subscriber of nopaque.jobsSubscribers) {
- subscriber.update(jobs_patch);
- }
- for (let subscriber of nopaque.queryResultsSubscribers) {
- subscriber.update(query_results_patch);
- }
- if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) {
- for (operation of jobs_patch) {
- /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */
- pathArray = operation.path.split("/").slice(2);
- if (operation.op === "replace" && pathArray[1] === "status") {
- if (nopaque.user.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;}
- nopaque.flash(`[${nopaque.user.jobs[pathArray[0]].title}] New status: ${operation.value}`, "job");
- }
- }
- }
-});
-
-nopaque.socket.on("foreign_user_data_stream_init", function(msg) {
- nopaque.foreignUser = JSON.parse(msg);
- for (let subscriber of nopaque.foreignCorporaSubscribers) {
- subscriber.init(nopaque.foreignUser.corpora);
- }
- for (let subscriber of nopaque.foreignJobsSubscribers) {
- subscriber.init(nopaque.foreignUser.jobs);
- }
- for (let subscriber of nopaque.foreignQueryResultsSubscribers) {
- subscriber.init(nopaque.foreignUser.query_results);
- }
-});
-
-nopaque.socket.on("foreign_user_data_stream_update", function(msg) {
- var patch;
-
- patch = JSON.parse(msg);
- nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch);
- corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora"));
- jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs"));
- query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results"));
- for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber.update(corpora_patch);}
- for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber.update(jobs_patch);}
- for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber.update(query_results_patch);}
-});
+ toast = M.toast({html: `${message}
+ `});
+ toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
+ toastActionElement.addEventListener('click', () => {toast.dismiss();});
+};
nopaque.Forms = {};
nopaque.Forms.init = function() {
@@ -163,30 +205,3 @@ nopaque.Forms.init = function() {
}
}
}
-
-
-nopaque.flash = function(message, category) {
- let toast;
- let toastActionElement;
-
- switch (category) {
- case "corpus":
- message = `book${message}`;
- break;
- case "error":
- message = `error${message}`;
- break;
- case "job":
- message = `work${message}`;
- break;
- default:
- message = `notifications${message}`;
- }
-
- toast = M.toast({html: `${message}
- `});
- toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
- toastActionElement.addEventListener('click', () => {toast.dismiss();});
-}
diff --git a/web/app/static/js/nopaque.lists.js b/web/app/static/js/nopaque.lists.js
index 0ce961ed..36ea8b48 100644
--- a/web/app/static/js/nopaque.lists.js
+++ b/web/app/static/js/nopaque.lists.js
@@ -1,6 +1,33 @@
class RessourceList {
- constructor(idOrElement, options = {}) {
- this.list = new List(idOrElement, {...RessourceList.options, ...options});
+ /* A wrapper class for the list.js list.
+ * This class is not meant to be used directly, instead it should be used as
+ * a template 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.valueNames = ['id'];
+ for (let element of this.list.valueNames) {
+ switch (typeof element) {
+ case 'object':
+ if (element.hasOwnProperty('name')) {this.valueNames.push(element.name);}
+ break;
+ case 'string':
+ this.valueNames.push(element);
+ break;
+ default:
+ console.error(`Unknown value name definition: ${element}`);
+ }
+ }
}
init(ressources) {
@@ -9,38 +36,27 @@ class RessourceList {
this.list.sort('id', {order: 'desc'});
}
-
- update(patch) {
- let item, pathArray;
-
- for (let operation of patch) {
- /*
- * '/{ressourceName}/{ressourceId}/{valueName}' -> ['{ressourceId}', {valueName}]
- * Example: '/jobs/1/status' -> ['1', 'status']
- */
- let [id, valueName] = operation.path.split("/").slice(2);
- switch(operation.op) {
- case 'add':
- this.add(operation.value);
- break;
- case 'remove':
- this.remove(id);
- break;
- case 'replace':
- this.replace(id, valueName, operation.value);
- break;
- default:
- break;
- }
- }
+ 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) {
- /* WORKAROUND: Set a callback function ('() => {return;}') to force List.js
- perform the add method asynchronous.
- * https://listjs.com/api/#add
- */
- this.list.add(values, () => {return;});
+ let ressources = Array.isArray(values) ? values : [values];
+ // Discard ressource values, that are not defined to be used in the list.
+ ressources = ressources.map(ressource => {
+ let cleanedRessource = {};
+ for (let [valueName, value] of Object.entries(ressource)) {
+ if (this.valueNames.includes(valueName)) {cleanedRessource[valueName] = value;}
+ }
+ return cleanedRessource;
+ });
+ // 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) {
@@ -48,35 +64,84 @@ class RessourceList {
}
replace(id, valueName, newValue) {
- if (!this.list.valuesNames.includes(valueName)) {return;}
- let item = this.list.get('id', id);
- item.values({[valueName]: newValue});
+ if (this.valueNames.includes(valueName)) {
+ let item = this.list.get('id', id)[0];
+ item.values({[valueName]: newValue});
+ }
}
}
-
-
RessourceList.options = {page: 5, pagination: [{innerWindow: 4, outerWindow: 1}]};
class CorpusList extends RessourceList {
- constructor(listElementId, options = {}) {
- let listElement = document.querySelector(`#${listElementId}`);
+ constructor(listElement, options = {}) {
super(listElement, {...CorpusList.options, ...options});
- listElement.addEventListener('click', (event) => {
- let actionButtonElement = event.target.closest('.action-button');
- if (actionButtonElement === null) {return;}
- let corpusId = event.target.closest('tr').dataset.id;
- let action = actionButtonElement.dataset.action;
- switch (action) {
- case 'analyse':
- window.location.href = nopaque.user.corpora[corpusId].analysis_url;
+ this.user.addEventListener('corporaInit', corpora => this.init(corpora));
+ this.user.addEventListener('corporaPatch', patch => this.patch(patch));
+ listElement.addEventListener('click', (event) => {this.onclick(event)});
+ }
+
+ onclick(event) {
+ let corpusId = event.target.closest('tr').dataset.id;
+ let actionButtonElement = event.target.closest('.action-button');
+ let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+ switch (action) {
+ case 'analyse':
+ window.location.href = nopaque.user.corpora[corpusId].analysis_url;
+ case 'delete':
+ let deleteModalHTML = `
+
+
Confirm corpus deletion
+
Do you really want to delete the corpus ${nopaque.user.corpora[corpusId].title}? All files will be permanently deleted!
+
+
+
`;
+ let deleteModalParentElement = document.querySelector('main');
+ deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
+ let deleteModalElement = deleteModalParentElement.lastChild;
+ let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
+ deleteModal.open();
+ break;
+ case 'view':
+ // TODO: handle unprepared corpora
+ window.location.href = nopaque.user.corpora[corpusId].url;
+ break;
+ default:
+ console.error(`Unknown action: ${action}`);
+ break;
+ }
+ }
+
+ patch(patch) {
+ for (let operation of patch) {
+ switch(operation.op) {
+ case 'add':
+ // Matches the only paths that should be handled here: /corpora/{corpusId}
+ if (/^\/corpora\/(\d+)$/.test(operation.path)) {this.add(operation.value);}
+ break;
+ case 'remove':
+ // See case 'add' ;)
+ if (/^\/corpora\/(\d+)$/.test(operation.path)) {
+ let [match, id] = operation.path.match(/^\/corpora\/(\d+)$/);
+ this.remove(corpusId);
+ }
+ break;
+ case 'replace':
+ // Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title}
+ if (/^\/corpora\/(\d+)\/(status|description|title)$/.test(operation.path)) {
+ let [match, id, valueName] = operation.path.match(/^\/corpora\/(\d+)\/(status|description|title)$/);
+ this.replace(id, valueName, operation.value);
+ }
+ break;
+ default:
+ break;
}
- });
- nopaque.corporaSubscribers.push(this);
+ }
}
}
-
-
CorpusList.options = {
item: `
book |
@@ -84,23 +149,80 @@ CorpusList.options = {
|
delete
- edit
search
+ send
|
`,
- valueNames: [{data: ['id']}, {name: "status", attr: "data-status"}, 'description', 'title']
+ valueNames: [{data: ['id']}, {name: 'status', attr: 'data-status'}, 'description', 'title']
};
class JobList extends RessourceList {
- constructor(listElementId, options = {}) {
- let listElement = document.querySelector(`#${listElementId}`);
+ constructor(listElement, options = {}) {
super(listElement, {...JobList.options, ...options});
- nopaque.jobsSubscribers.push(this);
+ this.user.addEventListener('jobsInit', jobs => this.init(jobs));
+ this.user.addEventListener('jobsPatch', patch => this.patch(patch));
+ listElement.addEventListener('click', (event) => {this.onclick(event)});
+ }
+
+ onclick(event) {
+ let jobId = event.target.closest('tr').dataset.id;
+ let actionButtonElement = event.target.closest('.action-button');
+ let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+ switch (action) {
+ case 'delete':
+ let deleteModalHTML = `
+
+
Confirm job deletion
+
Do you really want to delete the job ${this.user.data.jobs[jobId].title}? All files will be permanently deleted!
+
+
+
`;
+ let deleteModalParentElement = document.querySelector('main');
+ deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
+ let deleteModalElement = deleteModalParentElement.lastChild;
+ let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
+ deleteModal.open();
+ break;
+ case 'view':
+ window.location.href = this.user.data.jobs[jobId].url;
+ break;
+ default:
+ console.error(`Unknown action: "${action}"`);
+ break;
+ }
+ }
+
+ patch(patch) {
+ for (let operation of patch) {
+ switch(operation.op) {
+ case 'add':
+ // Matches the only paths that should be handled here: /jobs/{jobId}
+ if (/^\/jobs\/(\d+)$/.test(operation.path)) {this.add(operation.value);}
+ break;
+ case 'remove':
+ // See case add ;)
+ if (/^\/jobs\/(\d+)$/.test(operation.path)) {
+ let [match, id] = operation.path.match(/^\/jobs\/(\d+)$/);
+ this.remove(jobId);
+ }
+ break;
+ case 'replace':
+ // Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title}
+ if (/^\/jobs\/(\d+)\/(service|status|description|title)$/.test(operation.path)) {
+ let [match, id, valueName] = operation.path.match(/^\/jobs\/(\d+)\/(service|status|description|title)$/);
+ this.replace(id, valueName, operation.value);
+ }
+ break;
+ default:
+ break;
+ }
+ }
}
}
-
-
JobList.options = {
item: `
|
@@ -111,19 +233,17 @@ JobList.options = {
send
`,
- valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: "status", attr: "data-status"}, 'description', 'title']
+ valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: 'status', attr: 'data-status'}, 'description', 'title']
};
class QueryResultList extends RessourceList {
- constructor(listElementId, options = {}) {
- let listElement = document.querySelector(`#${listElementId}`);
+ constructor(listElement, options = {}) {
super(listElement, {...QueryResultList.options, ...options});
- nopaque.queryResultsSubscribers.push(this);
+ this.user.addEventListener('queryResultsInit', queryResults => this.init(queryResults));
+ this.user.addEventListener('queryResultsPatch', patch => this.init(patch));
}
}
-
-
QueryResultList.options = {
item: `
|
@@ -136,5 +256,3 @@ QueryResultList.options = {
`,
valueNames: [{data: ['id']}, 'corpus_title', 'description', 'query', 'title']
};
-
-export { CorpusList, JobList, QueryResultList };
diff --git a/web/app/templates/admin/user.html.j2 b/web/app/templates/admin/user.html.j2
index 97e3137a..78351735 100644
--- a/web/app/templates/admin/user.html.j2
+++ b/web/app/templates/admin/user.html.j2
@@ -36,16 +36,16 @@
-