mirror of
				https://gitlab.ub.uni-bielefeld.de/sfb1288inf/nopaque.git
				synced 2025-11-04 04:12:45 +00:00 
			
		
		
		
	Only reveal hashids to the ui
This commit is contained in:
		
							
								
								
									
										80
									
								
								app/static/js/nopaque/App.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								app/static/js/nopaque/App.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
class App {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.data = {users: {}};
 | 
			
		||||
    this.eventListeners = {'users.patch': []};
 | 
			
		||||
    this.socket = io({transports: ['websocket'], upgrade: false});
 | 
			
		||||
    this.socket.on('users.patch', patch => this.usersPatchHandler(patch));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get users() {return this.data.users;}
 | 
			
		||||
 | 
			
		||||
  addEventListener(type, listener) {
 | 
			
		||||
    if (!(type in this.eventListeners)) {throw `Unknown event type: ${type}`;}
 | 
			
		||||
    this.eventListeners[type].push(listener);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  flash(message, category) {
 | 
			
		||||
    let toast, 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 class="btn-flat toast-action white-text" data-action="close">
 | 
			
		||||
          <i class="material-icons">close</i>
 | 
			
		||||
        </button>
 | 
			
		||||
      `.trim()
 | 
			
		||||
    });
 | 
			
		||||
    toastCloseActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
 | 
			
		||||
    toastCloseActionElement.addEventListener('click', () => {toast.dismiss();});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getUserById(userId) {
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
      if (userId in this.data.users) {resolve(this.users[userId]);}
 | 
			
		||||
      this.socket.emit('users.user.get', userId, response => {
 | 
			
		||||
        if (response.code === 200) {
 | 
			
		||||
          this.data.users[userId] = response.payload;
 | 
			
		||||
          resolve(this.data.users[userId]);
 | 
			
		||||
        } else {
 | 
			
		||||
          reject(response);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re, match, userId, ressourceId, jobId, relationship;
 | 
			
		||||
    for (let operation of patch.filter(operation => operation.op === 'add')) {
 | 
			
		||||
      re = new RegExp(`^/users/([A-Za-z0-9]*)/corpora/([A-Za-z0-9]*)/(files)`);
 | 
			
		||||
      if (re.test(operation.path)) {
 | 
			
		||||
        [match, userId, ressourceId, relationship] = operation.path.match(re);
 | 
			
		||||
        if (!(relationship in this.users[userId].corpora[ressourceId])) {
 | 
			
		||||
          this.users[userId].corpora[ressourceId][relationship] = {};
 | 
			
		||||
        }
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
      re = new RegExp(`^/users/([A-Za-z0-9]*)/jobs/([A-Za-z0-9]*)/(inputs|results)`);
 | 
			
		||||
      if (re.test(operation.path)) {
 | 
			
		||||
        [match, userId, ressourceId, relationship] = operation.path.match(re);
 | 
			
		||||
        if (!(relationship in this.users[userId].jobs[ressourceId])) {
 | 
			
		||||
          this.users[userId].jobs[ressourceId][relationship] = {};
 | 
			
		||||
        }
 | 
			
		||||
        continue;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    this.data = jsonpatch.apply_patch(this.data, patch);
 | 
			
		||||
    for (let listener of this.eventListeners['users.patch']) {listener(patch);}
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										17
									
								
								app/static/js/nopaque/JobStatusNotifier.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/static/js/nopaque/JobStatusNotifier.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
class JobStatusNotifier {
 | 
			
		||||
  constructor(userId) {
 | 
			
		||||
    this.userId = userId;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re, filteredPatch, match, jobId;
 | 
			
		||||
    re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/status$`)
 | 
			
		||||
    filteredPatch = patch
 | 
			
		||||
      .filter(operation => operation.op === 'replace')
 | 
			
		||||
      .filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      [match, jobId] = operation.path.match(re);
 | 
			
		||||
      app.flash(`[<a href="/jobs/${jobId}">${app.users[this.userId].jobs[jobId].title}</a>] New status: ${operation.value}`, 'job');
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,31 +2,34 @@ 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);
 | 
			
		||||
    for (let exportCorpusTriggerElement of this.displayElement.querySelectorAll('.export-corpus-trigger')) {
 | 
			
		||||
      exportCorpusTriggerElement.addEventListener('click', () => this.requestCorpusExport());
 | 
			
		||||
    }
 | 
			
		||||
    app.socket.on(`export_corpus_${this.corpusId}`, () => this.downloadCorpus());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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].num_tokens, this.user.data.corpora[this.corpusId].max_num_tokens);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    let corpus;
 | 
			
		||||
    corpus = user.corpora[this.corpusId];
 | 
			
		||||
    this.setCreationDate(corpus.creation_date);
 | 
			
		||||
    this.setDescription(corpus.description);
 | 
			
		||||
    this.setLastEditedDate(corpus.last_edited_date);
 | 
			
		||||
    this.setStatus(corpus.status);
 | 
			
		||||
    this.setTitle(corpus.title);
 | 
			
		||||
    this.setTokenRatio(corpus.num_tokens, corpus.max_num_tokens);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let re;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
    let re, filteredPatch;
 | 
			
		||||
    re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}`);
 | 
			
		||||
    filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      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');
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/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;}
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/status$`);
 | 
			
		||||
          if (re.test(operation.path)) {this.status$(operation.value); break;}
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          break;
 | 
			
		||||
@@ -35,18 +38,19 @@ class CorpusDisplay extends RessourceDisplay {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  requestCorpusExport() {
 | 
			
		||||
    nopaque.appClient.socket.emit('export_corpus', this.user.data.corpora[this.corpusId].id);
 | 
			
		||||
    nopaque.appClient.flash('Preparing your corpus export...', 'corpus');
 | 
			
		||||
    app.socket.emit('export_corpus', app.users[this.userId].corpora[this.corpusId]);
 | 
			
		||||
    app.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');
 | 
			
		||||
    let downloadButton;
 | 
			
		||||
    app.flash('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();
 | 
			
		||||
    downloadButton = document.createElement('a');
 | 
			
		||||
    downloadButton.href = `/corpora/${app.users[this.userId].corpora[this.corpusId]}/download`;
 | 
			
		||||
    downloadButton.click();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setTitle(title) {
 | 
			
		||||
@@ -70,7 +74,7 @@ class CorpusDisplay extends RessourceDisplay {
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.build-corpus-trigger')) {
 | 
			
		||||
      if (status === 'unprepared' && Object.values(this.user.data.corpora[this.corpusId].files).length > 0) {
 | 
			
		||||
      if (status === 'unprepared' && Object.values(app.users[this.userId].corpora[this.corpusId].files).length > 0) {
 | 
			
		||||
        element.classList.remove('disabled');
 | 
			
		||||
      } else {
 | 
			
		||||
        element.classList.add('disabled');
 | 
			
		||||
@@ -90,13 +94,15 @@ class CorpusDisplay extends RessourceDisplay {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCreationDate(iso8601CreationDate) {
 | 
			
		||||
    let creationDate = new Date(iso8601CreationDate).toLocaleString("en-US");
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.corpus-creation-date')) {this.setElement(element, creationDate);}
 | 
			
		||||
  setCreationDate(creationDate) {
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.corpus-creation-date')) {
 | 
			
		||||
      this.setElement(element, creationDate.toLocaleString("en-US"));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setLastEditedDate(iso8601LastEditedDate) {
 | 
			
		||||
    let endDate = new Date(iso8601LastEditedDate).toLocaleString("en-US");
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.corpus-end-date')) {this.setElement(element, endDate);}
 | 
			
		||||
  setLastEditedDate(lastEditedDate) {
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.corpus-end-date')) {
 | 
			
		||||
      this.setElement(element, lastEditedDate.toLocaleString("en-US"));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,37 @@
 | 
			
		||||
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);
 | 
			
		||||
    this.jobId = this.displayElement.dataset.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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    let job = user.jobs[this.jobId];
 | 
			
		||||
    this.setCreationDate(job.creation_date);
 | 
			
		||||
    this.setEndDate(job.creation_date);
 | 
			
		||||
    this.setDescription(job.description);
 | 
			
		||||
    this.setService(job.service);
 | 
			
		||||
    this.setServiceArgs(job.service_args);
 | 
			
		||||
    this.setServiceVersion(job.service_version);
 | 
			
		||||
    this.setStatus(job.status);
 | 
			
		||||
    this.setTitle(job.title);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let re;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}`);
 | 
			
		||||
    let filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      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;}
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/end_date$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            this.setEndDate(operation.value);
 | 
			
		||||
            break;
 | 
			
		||||
          }
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/status$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            this.setStatus(operation.value);
 | 
			
		||||
            break;
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
          break;
 | 
			
		||||
@@ -63,14 +68,16 @@ class JobDisplay extends RessourceDisplay {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCreationDate(iso8601CreationDate) {
 | 
			
		||||
    let creationDate = new Date(iso8601CreationDate).toLocaleString("en-US");
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.job-creation-date')) {this.setElement(element, creationDate);}
 | 
			
		||||
  setCreationDate(creationDate) {
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.job-creation-date')) {
 | 
			
		||||
      this.setElement(element, creationDate.toLocaleString('en-US'));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setEndDate(iso8601EndDate) {
 | 
			
		||||
    let endDate = new Date(iso8601EndDate).toLocaleString("en-US");
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.job-end-date')) {this.setElement(element, endDate);}
 | 
			
		||||
  setEndDate(endDate) {
 | 
			
		||||
    for (let element of this.displayElement.querySelectorAll('.job-end-date')) {
 | 
			
		||||
      this.setElement(element, endDate.toLocaleString('en-US'));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setService(service) {
 | 
			
		||||
 
 | 
			
		||||
@@ -1,35 +1,14 @@
 | 
			
		||||
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;
 | 
			
		||||
    this.userId = this.displayElement.dataset.userId;
 | 
			
		||||
    app.addEventListener('users.patch', patch => this.usersPatchHandler(patch));
 | 
			
		||||
    app.getUserById(this.userId).then(user => this.init(user), error => {throw JSON.stringify(error);});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  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(user) {throw 'Not implemented';}
 | 
			
		||||
 | 
			
		||||
  init() {console.error('init method not implemented!');}
 | 
			
		||||
 | 
			
		||||
  patch() {console.error('patch method not implemented!');}
 | 
			
		||||
  usersPatchHandler(patch) {throw 'Not implemented';}
 | 
			
		||||
 | 
			
		||||
  setElement(element, value) {
 | 
			
		||||
    switch (element.tagName) {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,32 +2,32 @@ 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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    this._init(user.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 corpusFileElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (corpusFileElement === null) {throw 'Could not locate corpus file element';}
 | 
			
		||||
    let corpusFileId = corpusFileElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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 deleteModalHTML = `
 | 
			
		||||
          <div class="modal">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
              <h4>Confirm corpus deletion</h4>
 | 
			
		||||
              <p>Do you really want to delete the corpus file <b>${app.users[this.userId].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="/corpora/${this.corpusId}/files/${corpusFileId}/delete"><i class="material-icons left">delete</i>Delete</a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        `.trim();
 | 
			
		||||
        let deleteModalParentElement = document.querySelector('#modals');
 | 
			
		||||
        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
 | 
			
		||||
        let deleteModalElement = deleteModalParentElement.lastChild;
 | 
			
		||||
@@ -35,40 +35,37 @@ class CorpusFileList extends RessourceList {
 | 
			
		||||
        deleteModal.open();
 | 
			
		||||
        break;
 | 
			
		||||
      case 'download':
 | 
			
		||||
        window.location.href = this.user.data.corpora[this.corpusId].files[corpusFileId].download_url;
 | 
			
		||||
        window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}/download`;
 | 
			
		||||
        break;
 | 
			
		||||
      case 'view':
 | 
			
		||||
        if (corpusFileId !== '-1') {window.location.href = this.user.data.corpora[this.corpusId].files[corpusFileId].url;}
 | 
			
		||||
        window.location.href = `/corpora/${this.corpusId}/files/${corpusFileId}`;
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        console.error(`Unknown action: "${action}"`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let id, match, re, valueName;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)`);
 | 
			
		||||
    let filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      switch(operation.op) {
 | 
			
		||||
        case 'add':
 | 
			
		||||
          // 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+)$');
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {this.add(operation.value);}
 | 
			
		||||
          break;
 | 
			
		||||
        case 'remove':
 | 
			
		||||
          // See case add ;)
 | 
			
		||||
          re = new RegExp('^/corpora/' + this.user.data.corpora[this.corpusId].id + '/files/(\\d+)$');
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id] = operation.path.match(re);
 | 
			
		||||
            this.remove(id);
 | 
			
		||||
            [match, corpusFileId] = operation.path.match(re);
 | 
			
		||||
            this.remove(corpusFileId);
 | 
			
		||||
          }
 | 
			
		||||
          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)$');
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/${this.corpusId}/files/([A-Za-z0-9]*)/(author|filename|publishing_year|title)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(id, valueName, operation.value);
 | 
			
		||||
            [match, corpusFileId, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(corpusFileId, valueName, operation.value);
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
@@ -78,20 +75,29 @@ class CorpusFileList extends RessourceList {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  preprocessRessource(corpusFile) {
 | 
			
		||||
    return {id: corpusFile.id, author: corpusFile.author, filename: corpusFile.filename, publishing_year: corpusFile.publishing_year, title: corpusFile.title};
 | 
			
		||||
    return {
 | 
			
		||||
      id: corpusFile.id,
 | 
			
		||||
      author: corpusFile.author,
 | 
			
		||||
      creationDate: corpusFile.creation_date,
 | 
			
		||||
      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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, 'author', 'filename', 'publishing_year', 'title']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +1,32 @@
 | 
			
		||||
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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    super._init(user.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;
 | 
			
		||||
    let corpusElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (corpusElement === null) {throw 'Could not locate corpus element';}
 | 
			
		||||
    let corpusId = corpusElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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 deleteModalHTML = `
 | 
			
		||||
          <div class="modal">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
              <h4>Confirm corpus deletion</h4>
 | 
			
		||||
              <p>Do you really want to delete the corpus <b>${app.users[this.userId].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="/corpora/${corpusId}/delete"><i class="material-icons left">delete</i>Delete</a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        `.trim();
 | 
			
		||||
        let deleteModalParentElement = document.querySelector('#modals');
 | 
			
		||||
        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
 | 
			
		||||
        let deleteModalElement = deleteModalParentElement.lastChild;
 | 
			
		||||
@@ -33,37 +34,34 @@ class CorpusList extends RessourceList {
 | 
			
		||||
        deleteModal.open();
 | 
			
		||||
        break;
 | 
			
		||||
      case 'view':
 | 
			
		||||
        if (corpusId !== '-1') {window.location.href = this.user.data.corpora[corpusId].url;}
 | 
			
		||||
        window.location.href = `/corpora/${corpusId}`;
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        console.error(`Unknown action: ${action}`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let id, match, re, valueName;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)`);
 | 
			
		||||
    let filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      switch(operation.op) {
 | 
			
		||||
        case 'add':
 | 
			
		||||
          // Matches the only paths that should be handled here: /corpora/{corpusId}
 | 
			
		||||
          re = /^\/corpora\/(\d+)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {this.add(operation.value);}
 | 
			
		||||
          break;
 | 
			
		||||
        case 'remove':
 | 
			
		||||
          // See case 'add' ;)
 | 
			
		||||
          re = /^\/corpora\/(\d+)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id] = operation.path.match(re);
 | 
			
		||||
            this.remove(id);
 | 
			
		||||
            let [match, corpusId] = operation.path.match(re);
 | 
			
		||||
            this.remove(corpusId);
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        case 'replace':
 | 
			
		||||
          // Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title}
 | 
			
		||||
          re = /^\/corpora\/(\d+)\/(status|description|title)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/corpora/([A-Za-z0-9]*)/(status|description|title)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(id, valueName, operation.value);
 | 
			
		||||
            let [match, corpusId, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(corpusId, valueName, operation.value);
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
@@ -73,21 +71,26 @@ class CorpusList extends RessourceList {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  preprocessRessource(corpus) {
 | 
			
		||||
    return {id: corpus.id,
 | 
			
		||||
            status: corpus.status,
 | 
			
		||||
            description: corpus.description,
 | 
			
		||||
            title: corpus.title};
 | 
			
		||||
    return {
 | 
			
		||||
      id: corpus.id,
 | 
			
		||||
      creationDate: corpus.creation_date,
 | 
			
		||||
      description: corpus.description,
 | 
			
		||||
      status: corpus.status,
 | 
			
		||||
      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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, {name: 'status', attr: 'data-status'}, 'description', 'title']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -2,40 +2,46 @@ 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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    this._init(user.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');
 | 
			
		||||
    let jobInputElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (jobInputElement === null) {return;}
 | 
			
		||||
    let jobInputId = jobInputElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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;
 | 
			
		||||
        window.location.href = `/jobs/${this.jobId}/inputs/${jobInputId}/download`;
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        console.error(`Unknown action: "${action}"`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  usersPatchHandler(patch) {return;}
 | 
			
		||||
 | 
			
		||||
  preprocessRessource(jobInput) {
 | 
			
		||||
    return {id: jobInput.id, filename: jobInput.filename};
 | 
			
		||||
    return {
 | 
			
		||||
      id: jobInput.id,
 | 
			
		||||
      creationDate: jobInput.creation_date,
 | 
			
		||||
      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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, 'filename']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +1,32 @@
 | 
			
		||||
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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    this._init(user.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 jobElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (jobElement === null) {throw 'Could not locate job element';}
 | 
			
		||||
    let jobId = jobElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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 deleteModalHTML = `
 | 
			
		||||
          <div class="modal">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
              <h4>Confirm job deletion</h4>
 | 
			
		||||
              <p>Do you really want to delete the job <b>${app.users[this.userId].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="/jobs/${jobId}/delete"><i class="material-icons left">delete</i>Delete</a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        `.trim();
 | 
			
		||||
        let deleteModalParentElement = document.querySelector('#modals');
 | 
			
		||||
        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
 | 
			
		||||
        let deleteModalElement = deleteModalParentElement.lastChild;
 | 
			
		||||
@@ -33,37 +34,34 @@ class JobList extends RessourceList {
 | 
			
		||||
        deleteModal.open();
 | 
			
		||||
        break;
 | 
			
		||||
      case 'view':
 | 
			
		||||
        if (jobId !== '-1') {window.location.href = this.user.data.jobs[jobId].url;}
 | 
			
		||||
        window.location.href = `/jobs/${jobId}`;
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        console.error(`Unknown action: "${action}"`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let id, match, re, valueName;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)`);
 | 
			
		||||
    let filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      switch(operation.op) {
 | 
			
		||||
        case 'add':
 | 
			
		||||
          // Matches the only paths that should be handled here: /jobs/{jobId}
 | 
			
		||||
          re = /^\/jobs\/(\d+)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {this.add(operation.value);}
 | 
			
		||||
          break;
 | 
			
		||||
        case 'remove':
 | 
			
		||||
          // See case add ;)
 | 
			
		||||
          re = /^\/jobs\/(\d+)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id] = operation.path.match(re);
 | 
			
		||||
            this.remove(id);
 | 
			
		||||
            let [match, jobId] = operation.path.match(re);
 | 
			
		||||
            this.remove(jobId);
 | 
			
		||||
          }
 | 
			
		||||
          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)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/jobs/([A-Za-z0-9]*)/(service|status|description|title)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(id, valueName, operation.value);
 | 
			
		||||
            let [match, jobId, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(jobId, valueName, operation.value);
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
@@ -73,22 +71,27 @@ class JobList extends RessourceList {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  preprocessRessource(job) {
 | 
			
		||||
    return {id: job.id,
 | 
			
		||||
            service: job.service,
 | 
			
		||||
            status: job.status,
 | 
			
		||||
            description: job.description,
 | 
			
		||||
            title: job.title};
 | 
			
		||||
    return {
 | 
			
		||||
      id: job.id,
 | 
			
		||||
      creationDate: job.creation_date,
 | 
			
		||||
      description: job.description,
 | 
			
		||||
      service: job.service,
 | 
			
		||||
      status: job.status,
 | 
			
		||||
      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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: 'status', attr: 'data-status'}, 'description', 'title']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -2,37 +2,35 @@ 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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    super._init(user.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');
 | 
			
		||||
    let jobResultElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (jobResultElement === null) {return;}
 | 
			
		||||
    let jobResultId = jobResultElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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;
 | 
			
		||||
        window.location.href = `/jobs/${this.jobId}/results/${jobResultId}`;
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        console.error(`Unknown action: "${action}"`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let re;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)`);
 | 
			
		||||
    let filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      switch(operation.op) {
 | 
			
		||||
        case 'add':
 | 
			
		||||
          // 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+)$');
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/jobs/${this.jobId}/results/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {this.add(operation.value);}
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
@@ -56,16 +54,23 @@ class JobResultList extends RessourceList {
 | 
			
		||||
    } else {
 | 
			
		||||
      description = 'All result files created during this job';
 | 
			
		||||
    }
 | 
			
		||||
    return {id: jobResult.id, description: description, filename: jobResult.filename};
 | 
			
		||||
    return {
 | 
			
		||||
      id: jobResult.id,
 | 
			
		||||
      creationDate: jobResult.creation_date,
 | 
			
		||||
      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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, 'description', 'filename']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,31 +1,32 @@
 | 
			
		||||
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);
 | 
			
		||||
  init(user) {
 | 
			
		||||
    super.init(user.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 queryResultElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (queryResultElement === null) {return;}
 | 
			
		||||
    let queryResultId = queryResultElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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 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>${app.users[this.userId].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="/query_results/${queryResultId}/delete"><i class="material-icons left">delete</i>Delete</a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        `.trim();
 | 
			
		||||
        let deleteModalParentElement = document.querySelector('#modals');
 | 
			
		||||
        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
 | 
			
		||||
        let deleteModalElement = deleteModalParentElement.lastChild;
 | 
			
		||||
@@ -33,37 +34,34 @@ class QueryResultList extends RessourceList {
 | 
			
		||||
        deleteModal.open();
 | 
			
		||||
        break;
 | 
			
		||||
      case 'view':
 | 
			
		||||
        if (queryResultId !== '-1') {window.location.href = this.user.data.query_results[queryResultId].url;}
 | 
			
		||||
        window.location.href = `/query_results/${queryResultId}`;
 | 
			
		||||
        break;
 | 
			
		||||
      default:
 | 
			
		||||
        console.error(`Unknown action: "${action}"`);
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  patch(patch) {
 | 
			
		||||
    let id, match, re, valueName;
 | 
			
		||||
    for (let operation of patch) {
 | 
			
		||||
  usersPatchHandler(patch) {
 | 
			
		||||
    let re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)`);
 | 
			
		||||
    let filteredPatch = patch.filter(operation => re.test(operation.path));
 | 
			
		||||
    for (let operation of filteredPatch) {
 | 
			
		||||
      switch(operation.op) {
 | 
			
		||||
        case 'add':
 | 
			
		||||
          // Matches the only paths that should be handled here: /jobs/{jobId}
 | 
			
		||||
          re = /^\/query_results\/(\d+)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {this.add(operation.value);}
 | 
			
		||||
          break;
 | 
			
		||||
        case 'remove':
 | 
			
		||||
          // See case add ;)
 | 
			
		||||
          re = /^\/query_results\/(\d+)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id] = operation.path.match(re);
 | 
			
		||||
            this.remove(id);
 | 
			
		||||
            let [match, queryResultId] = operation.path.match(re);
 | 
			
		||||
            this.remove(queryResultId);
 | 
			
		||||
          }
 | 
			
		||||
          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)$/;
 | 
			
		||||
          re = new RegExp(`^/users/${this.userId}/query_results/([A-Za-z0-9]*)/(corpus_title|description|query|title)$`);
 | 
			
		||||
          if (re.test(operation.path)) {
 | 
			
		||||
            [match, id, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(id, valueName, operation.value);
 | 
			
		||||
            let [match, queryResultId, valueName] = operation.path.match(re);
 | 
			
		||||
            this.replace(queryResultId, valueName, operation.value);
 | 
			
		||||
          }
 | 
			
		||||
          break;
 | 
			
		||||
        default:
 | 
			
		||||
@@ -73,21 +71,26 @@ class QueryResultList extends RessourceList {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  preprocessRessource(queryResult) {
 | 
			
		||||
    return {id: queryResult.id,
 | 
			
		||||
            corpus_title: queryResult.corpus_title,
 | 
			
		||||
            description: queryResult.description,
 | 
			
		||||
            query: queryResult.query,
 | 
			
		||||
            title: queryResult.title};
 | 
			
		||||
    return {
 | 
			
		||||
      id: queryResult.id,
 | 
			
		||||
      corpus_title: queryResult.corpus_title,
 | 
			
		||||
      creationDate: queryResult.creation_date,
 | 
			
		||||
      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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, 'corpus_title', 'description', 'query', 'title']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -4,87 +4,68 @@ class RessourceList {
 | 
			
		||||
   * 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"> </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.innerHTML = `
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td class="row" colspan="100%">
 | 
			
		||||
          <div class="col s12"> </div>
 | 
			
		||||
          <div class="col s3 m2 xl1">
 | 
			
		||||
            <div class="preloader-wrapper active">
 | 
			
		||||
              <div class="spinner-layer spinner-green-only">
 | 
			
		||||
                <div class="circle-clipper left">
 | 
			
		||||
                  <div class="circle"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="gap-patch">
 | 
			
		||||
                  <div class="circle"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="circle-clipper right">
 | 
			
		||||
                  <div class="circle"></div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="col s9 m6 xl5">
 | 
			
		||||
            <span class="card-title">Waiting for data...</span>
 | 
			
		||||
            <p>This list is not initialized yet.</p>
 | 
			
		||||
          </div>
 | 
			
		||||
        </td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    `.trim();
 | 
			
		||||
    this.list.list.style.cursor = 'pointer';
 | 
			
		||||
    this.userId = listElement.dataset.userId;
 | 
			
		||||
    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;
 | 
			
		||||
    if (this.userId) {
 | 
			
		||||
      app.addEventListener('users.patch', patch => this.usersPatchHandler(patch));
 | 
			
		||||
      app.getUserById(this.userId).then(
 | 
			
		||||
        user => this.init(user),
 | 
			
		||||
        error => {throw JSON.stringify(error);}
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init(ressources) {
 | 
			
		||||
  _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>`;
 | 
			
		||||
    let emptyListElementHTML = `
 | 
			
		||||
      <tr class="show-if-only-child">
 | 
			
		||||
        <td colspan="100%">
 | 
			
		||||
          <span class="card-title"><i class="left material-icons" style="font-size: inherit;">file_download</i>Nothing here...</span>
 | 
			
		||||
          <p>No ressource available.</p>
 | 
			
		||||
        </td>
 | 
			
		||||
      </tr>
 | 
			
		||||
    `.trim();
 | 
			
		||||
    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!');
 | 
			
		||||
  }
 | 
			
		||||
  init(user) {throw 'Not implemented';}
 | 
			
		||||
 | 
			
		||||
  usersPatchHandler(patch) {throw 'Not implemented';}
 | 
			
		||||
 | 
			
		||||
  preprocessRessource() {throw '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
 | 
			
		||||
    ressources = ressources.map(ressource => this.preprocessRessource(ressource));
 | 
			
		||||
    this.list.add(ressources, () => {
 | 
			
		||||
      this.list.sort('id', {order: 'desc'});
 | 
			
		||||
    });
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,32 @@
 | 
			
		||||
class UserList extends RessourceList {
 | 
			
		||||
  constructor(listElement, options = {}) {
 | 
			
		||||
    super(listElement, {...UserList.options, ...options});
 | 
			
		||||
    users = undefined;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init(users) {
 | 
			
		||||
    this.users = users;
 | 
			
		||||
    super.init(users);
 | 
			
		||||
    super._init(Object.values(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 userElement = event.target.closest('tr[data-id]');
 | 
			
		||||
    if (userElement === null) {return;}
 | 
			
		||||
    let userId = userElement.dataset.id;
 | 
			
		||||
    let actionButtonElement = event.target.closest('.action-button[data-action]');
 | 
			
		||||
    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 deleteModalHTML = `
 | 
			
		||||
          <div class="modal">
 | 
			
		||||
            <div class="modal-content">
 | 
			
		||||
              <h4>Confirm user deletion</h4>
 | 
			
		||||
              <p>Do you really want to delete user <b>${userId}</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>
 | 
			
		||||
        `.trim();
 | 
			
		||||
        let deleteModalParentElement = document.querySelector('#modals');
 | 
			
		||||
        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
 | 
			
		||||
        let deleteModalElement = deleteModalParentElement.lastChild;
 | 
			
		||||
@@ -37,35 +37,38 @@ class UserList extends RessourceList {
 | 
			
		||||
        window.location.href = `/admin/users/${userId}/edit`;
 | 
			
		||||
        break;
 | 
			
		||||
      case 'view':
 | 
			
		||||
        if (userId !== '-1') {window.location.href = `/admin/users/${userId}`;}
 | 
			
		||||
        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).toLocaleString("en-US"),
 | 
			
		||||
            role: user.role.name};
 | 
			
		||||
    return {
 | 
			
		||||
      id: user.id,
 | 
			
		||||
      id_: user.id,
 | 
			
		||||
      username: user.username,
 | 
			
		||||
      email: user.email,
 | 
			
		||||
      last_seen: user.last_seen.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>`,
 | 
			
		||||
  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>
 | 
			
		||||
  `.trim(),
 | 
			
		||||
  valueNames: [{data: ['id']}, 'id_', 'username', 'email', 'last_seen', 'role']
 | 
			
		||||
};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,177 +1,3 @@
 | 
			
		||||
class AppClient {
 | 
			
		||||
  constructor(currentUserId) {
 | 
			
		||||
    if (currentUserId) {
 | 
			
		||||
      this.socket = io({transports: ['websocket'], upgrade: false});
 | 
			
		||||
      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.
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user