From f56e951b71244549497c9a9b3aa310482e71241f Mon Sep 17 00:00:00 2001
From: Inga Kirschnick <inga.kirschnick@uni-bielefeld.de>
Date: Tue, 12 Sep 2023 16:42:28 +0200
Subject: [PATCH] Locking end-tag of structural attributes, ...

---
 app/static/css/queryBuilder.css               |   8 +-
 .../CorpusAnalysisConcordance.js              |   2 +-
 app/static/js/CorpusAnalysis/QueryBuilder.js  |  28 ++---
 .../ElementReferencesQueryBuilder.js          |   3 +
 .../GeneralFunctionsQueryBuilder.js           | 106 ++++++++++++++----
 ...alAttributeBuilderFunctionsQueryBuilder.js |  46 ++++++--
 ...enAttributeBuilderFunctionsQueryBuilder.js |   9 --
 app/static/js/Utils.js                        |  20 ++++
 .../query_builder/_query_builder.html.j2      |  12 +-
 9 files changed, 175 insertions(+), 59 deletions(-)

diff --git a/app/static/css/queryBuilder.css b/app/static/css/queryBuilder.css
index 4886e9a2..f48a4a64 100644
--- a/app/static/css/queryBuilder.css
+++ b/app/static/css/queryBuilder.css
@@ -7,6 +7,10 @@
   margin-top: 23px;
 }
 
+#corpus-analysis-concordance-query-builder-input-field-placeholder {
+  color: #9E9E9E;
+}
+
 .modal-content {
   overflow-x: hidden;
 }
@@ -109,10 +113,10 @@
 }
 
 [data-type="start-empty-entity"], [data-type="start-entity"], [data-type="end-entity"] {
-  background-color: #A6E22D;
+  background-color: #a6e22d;
 }
 
-[data-type="start-text-annotation"]{
+[data-type="text-annotation"]{
   background-color: #2FBBAB;
 }
 
diff --git a/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js b/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js
index 91ce0f68..2263fbde 100644
--- a/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js
+++ b/app/static/js/CorpusAnalysis/CorpusAnalysisConcordance.js
@@ -33,7 +33,7 @@ class CorpusAnalysisConcordance {
 
   async submitForm(queryModeId) {
     this.app.disableActionElements();
-    let queryBuilderQuery = document.querySelector('#corpus-analysis-concordance-query-preview').innerHTML.trim();
+    let queryBuilderQuery = Utils.unescape(document.querySelector('#corpus-analysis-concordance-query-preview').innerHTML.trim());
     let expertModeQuery = this.elements.expertModeForm.query.value.trim();
     let query = queryModeId === 'corpus-analysis-concordance-expert-mode-form' ? expertModeQuery : queryBuilderQuery;
     let form = queryModeId === 'corpus-analysis-concordance-expert-mode-form' ? this.elements.expertModeForm : this.elements.queryBuilderForm;
diff --git a/app/static/js/CorpusAnalysis/QueryBuilder.js b/app/static/js/CorpusAnalysis/QueryBuilder.js
index 51b7653b..8d86c06b 100644
--- a/app/static/js/CorpusAnalysis/QueryBuilder.js
+++ b/app/static/js/CorpusAnalysis/QueryBuilder.js
@@ -7,26 +7,28 @@ class ConcordanceQueryBuilder {
     this.tokenAttributeBuilderFunctions = new TokenAttributeBuilderFunctionsQueryBuilder(this.elements);
     this.structuralAttributeBuilderFunctions = new StructuralAttributeBuilderFunctionsQueryBuilder(this.elements);
 
+    // Eventlisteners for the incidence modifiers. There are two different types of incidence modifiers: token and character incidence modifiers.
     document.querySelectorAll('.incidence-modifier-selection').forEach(button => {
-      if (button.parentNode.parentNode.id === 'corpus-analysis-concordance-token-incidence-modifiers-dropdown') {
-        button.addEventListener('click', () => {
-          this.generalFunctions.tokenIncidenceModifierHandler(button.dataset.token, button.innerHTML);});
-      } else if (button.parentNode.parentNode.id === 'corpus-analysis-concordance-character-incidence-modifiers-dropdown') {
-        button.addEventListener('click', () => {this.tokenAttributeBuilderFunctions.characterIncidenceModifierHandler(button);});
+      let dropdownId = button.parentNode.parentNode.id;
+      if (dropdownId === 'corpus-analysis-concordance-token-incidence-modifiers-dropdown') {
+        button.addEventListener('click', () => this.generalFunctions.tokenIncidenceModifierHandler(button.dataset.token, button.innerHTML));
+      } else if (dropdownId === 'corpus-analysis-concordance-character-incidence-modifiers-dropdown') {
+        button.addEventListener('click', () => this.tokenAttributeBuilderFunctions.characterIncidenceModifierHandler(button));
       }
     });
+    
+    // Eventlisteners for the submit of n- and m-values of the incidence modifier modal for "exactly n" or "between n and m".
     document.querySelectorAll('.n-m-submit-button').forEach(button => {
-      if (button.dataset.modalId === 'corpus-analysis-concordance-exactly-n-token-modal' || button.dataset.modalId === 'corpus-analysis-concordance-between-nm-token-modal') {
-        button.addEventListener('click', () => {
-          this.generalFunctions.tokenNMSubmitHandler(button.dataset.modalId);
-        });
-      } else if (button.dataset.modalId === 'corpus-analysis-concordance-exactly-n-character-modal' || button.dataset.modalId === 'corpus-analysis-concordance-between-nm-character-modal') {
-        button.addEventListener('click', () => {
-          this.generalFunctions.characterNMSubmitHandler(button.dataset.modalId);
-        });
+      let modalId = button.dataset.modalId;
+      if (modalId === 'corpus-analysis-concordance-exactly-n-token-modal' || modalId === 'corpus-analysis-concordance-between-nm-token-modal') {
+        button.addEventListener('click', () => this.generalFunctions.tokenNMSubmitHandler(modalId));
+      } else if (modalId === 'corpus-analysis-concordance-exactly-n-character-modal' || modalId === 'corpus-analysis-concordance-between-nm-character-modal') {
+        button.addEventListener('click', () => this.generalFunctions.characterNMSubmitHandler(modalId));
       }
     });
 
+    document.querySelector('#corpus-analysis-concordance-text-annotation-submit').addEventListener('click', () => this.structuralAttributeBuilderFunctions.textAnnotationSubmitHandler());
+
     this.elements.positionalAttrModal = M.Modal.init(
       document.querySelector('#corpus-analysis-concordance-positional-attr-modal'), 
       {
diff --git a/app/static/js/CorpusAnalysis/QueryBuilder/ElementReferencesQueryBuilder.js b/app/static/js/CorpusAnalysis/QueryBuilder/ElementReferencesQueryBuilder.js
index c6dcd257..17df5ed2 100644
--- a/app/static/js/CorpusAnalysis/QueryBuilder/ElementReferencesQueryBuilder.js
+++ b/app/static/js/CorpusAnalysis/QueryBuilder/ElementReferencesQueryBuilder.js
@@ -9,6 +9,9 @@ class ElementReferencesQueryBuilder {
     this.sentenceElement = document.querySelector('[data-structural-attr-modal-action-button="sentence"]');
     this.entityElement = document.querySelector('[data-structural-attr-modal-action-button="entity"]');
     this.textAnnotationElement = document.querySelector('[data-structural-attr-modal-action-button="text-annotation"]');
+    this.englishEntTypeSelection = document.querySelector('#corpus-analysis-concordance-english-ent-type-selection');
+    this.germanEntTypeSelection = document.querySelector('#corpus-analysis-concordance-german-ent-type-selection');
+    this.textAnnotationSelection = document.querySelector('#corpus-analysis-concordance-text-annotation-options');
 
     // Token Attribute Builder Elements
     this.positionalAttrModal = M.Modal.getInstance(document.querySelector('#corpus-analysis-concordance-positional-attr-modal'));
diff --git a/app/static/js/CorpusAnalysis/QueryBuilder/GeneralFunctionsQueryBuilder.js b/app/static/js/CorpusAnalysis/QueryBuilder/GeneralFunctionsQueryBuilder.js
index e718e811..bfe4fa7b 100644
--- a/app/static/js/CorpusAnalysis/QueryBuilder/GeneralFunctionsQueryBuilder.js
+++ b/app/static/js/CorpusAnalysis/QueryBuilder/GeneralFunctionsQueryBuilder.js
@@ -10,36 +10,96 @@ class GeneralFunctionsQueryBuilder {
   }
 
   updateChipList() {
-    this.elements.queryChipElements = this.elements.queryInputField.querySelectorAll('.chip');
+    this.elements.queryChipElements = this.elements.queryInputField.querySelectorAll('.query-component');
   }
 
-  queryChipFactory(dataType, prettyQueryText, queryText, index = null) {
+  removePlaceholder() {
+    let placeholder = this.elements.queryInputField.querySelector('#corpus-analysis-concordance-query-builder-input-field-placeholder');  
+    if (placeholder) {
+      this.elements.queryInputField.innerHTML = '';
+    }
+  }
+
+  addPlaceholder() {
+    let placeholder = Utils.HTMLToElement('<span id="corpus-analysis-concordance-query-builder-input-field-placeholder">Click on a button to add a query component</span>');
+    this.elements.queryInputField.appendChild(placeholder);
+  }
+  
+  resetMaterializeSelection(selectionElements, value = "default") {
+    selectionElements.forEach(selectionElement => {
+      selectionElement.querySelector(`option[value=${value}]`).selected = true;
+      let instance = M.FormSelect.getInstance(selectionElement);
+      instance.destroy();
+      M.FormSelect.init(selectionElement);
+    })
+  }
+
+
+  queryChipFactory(dataType, prettyQueryText, queryText, index = null, isClosingTag = false) {
+    // Creates a new query chip element, adds Eventlisteners for selection, deletion and drag and drop and appends it to the query input field.
+    
     queryText = Utils.escape(queryText);
     prettyQueryText = Utils.escape(prettyQueryText);
     let queryChipElement = Utils.HTMLToElement(
       `
-        <span class="chip query-component" data-type="${dataType}" data-query="${queryText}" draggable="true">
+        <span class="chip query-component" data-type="${dataType}" data-query="${queryText}" draggable="true" data-closing-tag="${isClosingTag}">
           ${prettyQueryText}
-          <i class="material-icons close">close</i>
+          ${isClosingTag ? '<i class="material-icons" style="padding-top:5px; font-size:20px; cursor:pointer;">lock_open</i>' : '<i class="material-icons close">close</i>'}
         </span>
       `
     );
-
-    queryChipElement.addEventListener('click', () => this.selectChipElement(queryChipElement));
-    queryChipElement.querySelector('i').addEventListener('click', () => this.deleteChipElement(queryChipElement));
-    
+    this.actionListeners(queryChipElement, isClosingTag);
     queryChipElement.addEventListener('dragstart', this.handleDragStart.bind(this, queryChipElement));
     queryChipElement.addEventListener('dragend', this.handleDragEnd);
-    if (index !== null) {
-      this.elements.queryInputField.insertBefore(queryChipElement, this.elements.queryChipElements[index]);
+
+    // Ensures that metadata is always at the end of the query and if an index is given, inserts the query chip at the given index and if there is a closing tag, inserts the query chip before the closing tag.
+    this.removePlaceholder();
+    let lastChild = this.elements.queryInputField.lastChild;
+    let isLastChildTextAnnotation = lastChild && lastChild.dataset.type === 'text-annotation';
+    if (!index) {
+      let closingTagElement = this.elements.queryInputField.querySelector('[data-closing-tag="true"]');
+      if (closingTagElement) {
+        index = Array.from(this.elements.queryInputField.children).indexOf(closingTagElement);
+      }
+    }
+    if (index || isLastChildTextAnnotation) {
+      let insertingElement = isLastChildTextAnnotation ? lastChild : this.elements.queryChipElements[index];
+      this.elements.queryInputField.insertBefore(queryChipElement, insertingElement);
     } else {
       this.elements.queryInputField.appendChild(queryChipElement);
     }
+
     this.updateChipList();
     this.queryPreviewBuilder();
   }
 
+  actionListeners(queryChipElement, isClosingTag) {
+    let notQuantifiableDataTypes = ['start-sentence', 'end-sentence', 'start-entity', 'start-empty-entity', 'end-entity', 'token-incidence-modifier'];
+    queryChipElement.addEventListener('click', (event) => {
+      if (event.target.classList.contains('chip')) {
+        if (!notQuantifiableDataTypes.includes(queryChipElement.dataset.type)) {
+          this.selectChipElement(queryChipElement);
+        }
+      }
+    });
+    queryChipElement.querySelector('i').addEventListener('click', () => {
+      if (isClosingTag) {
+        this.lockClosingChipElement(queryChipElement);
+      } else {
+        this.deleteChipElement(queryChipElement);
+      }
+    });
+  }
+
+  lockClosingChipElement(queryChipElement) {
+    let chipIndex = Array.from(this.elements.queryInputField.children).indexOf(queryChipElement);
+    this.queryChipFactory(queryChipElement.dataset.type, queryChipElement.firstChild.textContent, queryChipElement.dataset.query, chipIndex+1);
+    this.deleteChipElement(queryChipElement);
+    this.updateChipList();
+  }
+
   handleDragStart(queryChipElement, event) {
+    // is called when a query chip is dragged. It creates a dropzone (in form of a chip) for the dragged chip and adds it to the query input field.
     let queryChips = this.elements.queryInputField.querySelectorAll('.query-component');
     setTimeout(() => {
       let targetChipElement = Utils.HTMLToElement('<span class="chip drop-target">Drop here</span>');
@@ -81,6 +141,7 @@ class GeneralFunctionsQueryBuilder {
   }
 
   queryPreviewBuilder() {
+    // Builds the query preview in the form of pure CQL and displays it in the query preview field.
     let queryPreview = document.querySelector('#corpus-analysis-concordance-query-preview');
     let queryInputFieldContent = [];
     this.elements.queryChipElements.forEach(element => {
@@ -92,17 +153,15 @@ class GeneralFunctionsQueryBuilder {
     });
   
     let queryString = queryInputFieldContent.join(' ');
-    if (queryString.includes(' +')) {
-      queryString = queryString.replace(/ \+/g, '+');
-    }
-    if (queryString.includes(' *')) {
-      queryString = queryString.replace(/ \*/g, '*');
-    }
-    if (queryString.includes(' ?')) {
-      queryString = queryString.replace(/ \?/g, '?');
-    }
-    if (queryString.includes(' {')) {
-      queryString = queryString.replace(/ \{/g, '{');
+    let replacements = {
+      ' +': '+',
+      ' *': '*',
+      ' ?': '?',
+      ' {': '{'
+    };
+
+    for (let key in replacements) {
+      queryString = queryString.replace(key, replacements[key]);
     }
     queryString += ';';
   
@@ -117,6 +176,9 @@ class GeneralFunctionsQueryBuilder {
       this.elements.entityElement.innerHTML = 'Entity';
     }
     this.elements.queryInputField.removeChild(attr);
+    if (this.elements.queryInputField.children.length === 0) {
+      this.addPlaceholder();
+    }
     this.updateChipList();
     this.queryPreviewBuilder();
   }
@@ -135,6 +197,7 @@ class GeneralFunctionsQueryBuilder {
   }
 
   tokenIncidenceModifierHandler(incidenceModifier, incidenceModifierPretty) {
+    // Adds a token incidence modifier to the query input field.
     let selectedChip = this.elements.queryInputField.querySelector('.chip.teal');
     let selectedChipIndex = Array.from(this.elements.queryInputField.children).indexOf(selectedChip);
     this.queryChipFactory('token-incidence-modifier', incidenceModifierPretty, incidenceModifier, selectedChipIndex+1);
@@ -142,6 +205,7 @@ class GeneralFunctionsQueryBuilder {
   }
 
   tokenNMSubmitHandler(modalId) {
+    // Adds a token incidence modifier (exactly n or between n and m) to the query input field.
     let modal = document.querySelector(`#${modalId}`);
     let input_n = modal.querySelector('.n-m-input[data-value-type="n"]').value;
     let input_m = modal.querySelector('.n-m-input[data-value-type="m"]') || undefined;
diff --git a/app/static/js/CorpusAnalysis/QueryBuilder/StructuralAttributeBuilderFunctionsQueryBuilder.js b/app/static/js/CorpusAnalysis/QueryBuilder/StructuralAttributeBuilderFunctionsQueryBuilder.js
index 742533de..c9bb402c 100644
--- a/app/static/js/CorpusAnalysis/QueryBuilder/StructuralAttributeBuilderFunctionsQueryBuilder.js
+++ b/app/static/js/CorpusAnalysis/QueryBuilder/StructuralAttributeBuilderFunctionsQueryBuilder.js
@@ -10,24 +10,22 @@ class StructuralAttributeBuilderFunctionsQueryBuilder extends GeneralFunctionsQu
     });
     document.querySelector('.ent-type-selection-action[data-ent-type="any"]').addEventListener('click', () => {
       this.queryChipFactory('start-empty-entity', 'Entity Start', '<ent>');
-      this.queryChipFactory('end-entity', 'Entity End', '</ent>');
-      this.elements.structuralAttrModal.close();
+      this.queryChipFactory('end-entity', 'Entity End', '</ent>', null, true);
+      this.resetAndCloseStructuralAttrModal();
     });
     document.querySelector('.ent-type-selection-action[data-ent-type="english"]').addEventListener('change', (event) => {
       this.queryChipFactory('start-entity', `Entity Type=${event.target.value}`, `<ent_type="${event.target.value}">`);
-      this.queryChipFactory('end-entity', 'Entity End', '</ent_type>');
-      this.elements.structuralAttrModal.close();
+      this.queryChipFactory('end-entity', 'Entity End', '</ent_type>', null, true);
+      this.resetAndCloseStructuralAttrModal();
     });
-
-
   }
 
   actionButtonInStrucAttrModalHandler(action) {
     switch (action) {
       case 'sentence':
         this.queryChipFactory('start-sentence', 'Sentence Start', '<s>');
-        this.queryChipFactory('end-sentence', 'Sentence End', '</s>');
-        this.elements.structuralAttrModal.close();
+        this.queryChipFactory('end-sentence', 'Sentence End', '</s>', null, true);
+        this.resetAndCloseStructuralAttrModal();
         break;
       case 'entity':
         this.toggleClass(['entity-builder'], 'hide', 'toggle');
@@ -42,4 +40,36 @@ class StructuralAttributeBuilderFunctionsQueryBuilder extends GeneralFunctionsQu
     }
   }
 
+  textAnnotationSubmitHandler() {
+    let noValueMetadataMessage = document.querySelector('#corpus-analysis-concordance-no-value-metadata-message');
+    let textAnnotationSubmit = document.querySelector('#corpus-analysis-concordance-text-annotation-submit');
+    let textAnnotationInput = document.querySelector('#corpus-analysis-concordance-text-annotation-input');
+    let textAnnotationOptions = document.querySelector('#corpus-analysis-concordance-text-annotation-options');
+
+    if (textAnnotationInput.value === '') {
+      textAnnotationSubmit.classList.add('red');
+      noValueMetadataMessage.classList.remove('hide');
+      setTimeout(() => {
+        textAnnotationSubmit.classList.remove('red');
+      }, 500);
+      setTimeout(() => {
+        noValueMetadataMessage.classList.add('hide');
+      }, 3000);
+    } else {
+      let queryText = `:: match.text_${textAnnotationOptions.value}="${textAnnotationInput.value}"`;
+      this.queryChipFactory('text-annotation', `${textAnnotationOptions.value}=${textAnnotationInput.value}`, queryText);
+      this.resetAndCloseStructuralAttrModal();
+    }
+  }
+
+  resetAndCloseStructuralAttrModal() {
+    let textAnnotatinInput = document.querySelector('#corpus-analysis-concordance-text-annotation-input');
+    textAnnotatinInput.value = '';
+    this.resetMaterializeSelection([this.elements.englishEntTypeSelection, this.elements.germanEntTypeSelection]);
+    this.resetMaterializeSelection([this.elements.textAnnotationSelection], 'address');
+
+    this.toggleClass(['entity-builder', 'text-annotation-builder'], 'hide', 'add');
+    this.elements.structuralAttrModal.close();
+  }
+
 }
diff --git a/app/static/js/CorpusAnalysis/QueryBuilder/TokenAttributeBuilderFunctionsQueryBuilder.js b/app/static/js/CorpusAnalysis/QueryBuilder/TokenAttributeBuilderFunctionsQueryBuilder.js
index dff14554..7caa8707 100644
--- a/app/static/js/CorpusAnalysis/QueryBuilder/TokenAttributeBuilderFunctionsQueryBuilder.js
+++ b/app/static/js/CorpusAnalysis/QueryBuilder/TokenAttributeBuilderFunctionsQueryBuilder.js
@@ -331,13 +331,4 @@ class TokenAttributeBuilderFunctionsQueryBuilder extends GeneralFunctionsQueryBu
     this.resetMaterializeSelection([this.elements.englishPosSelection, this.elements.germanPosSelection, this.elements.simplePosSelection]);
     this.resetMaterializeSelection([this.elements.positionalAttrSelection], "word");
   }
-
-  resetMaterializeSelection(selectionElements, value = "default") {
-    selectionElements.forEach(selectionElement => {
-      selectionElement.querySelector(`option[value=${value}]`).selected = true;
-      let instance = M.FormSelect.getInstance(selectionElement);
-      instance.destroy();
-      M.FormSelect.init(selectionElement);
-    })
-  }
 }
diff --git a/app/static/js/Utils.js b/app/static/js/Utils.js
index 79879c75..0377bc56 100644
--- a/app/static/js/Utils.js
+++ b/app/static/js/Utils.js
@@ -16,6 +16,26 @@ class Utils {
     });
   };
 
+  static unescape(escapedText) {
+    var table = {
+      'lt': '<',
+      'gt': '>',
+      'quot': '"',
+      'apos': "'",
+      'amp': '&',
+      '#10': '\r',
+      '#13': '\n'
+    };
+    
+    return escapedText.replace(/&(#?\w+);/g, (match, entity) => {
+      if (table.hasOwnProperty(entity)) {
+        return table[entity];
+      }
+      
+      return match;
+    });
+}
+
   static HTMLToElement(HTMLString) {
     let templateElement = document.createElement('template');
     templateElement.innerHTML = HTMLString.trim();
diff --git a/app/templates/corpora/_analysis/query_builder/_query_builder.html.j2 b/app/templates/corpora/_analysis/query_builder/_query_builder.html.j2
index d6452358..d6343e95 100644
--- a/app/templates/corpora/_analysis/query_builder/_query_builder.html.j2
+++ b/app/templates/corpora/_analysis/query_builder/_query_builder.html.j2
@@ -2,7 +2,9 @@
 <form id="corpus-analysis-concordance-query-builder-form">
   <div class="row">
     <div class="col s9" id="corpus-analysis-concordance-query-builder-input-field-container">
-      <div id="corpus-analysis-concordance-query-builder-input-field"></div>
+      <div id="corpus-analysis-concordance-query-builder-input-field">
+        <p id="corpus-analysis-concordance-query-builder-input-field-placeholder">Click on the buttons below to build your query.</p>
+      </div>
     </div>
     <div class="input-field col s3">
       <i class="material-icons prefix">arrow_forward</i>
@@ -75,8 +77,8 @@
         <a class="btn waves-effect waves-light col ent-type-selection-action" data-ent-type="any">Add Entity of any type</a>
         <p class="col s1 l1"></p>
         <div class= "input-field col s3">
-            <select name="englishenttype" class="ent-type-selection-action" data-ent-type="english">
-              <option value="" disabled selected>English ent_type</option>
+            <select id="corpus-analysis-concordance-english-ent-type-selection" name="englishenttype" class="ent-type-selection-action" data-ent-type="english">
+              <option value="default" disabled selected>English ent_type</option>
               <option value="CARDINAL">CARDINAL</option>
               <option value="DATE">DATE</option>
               <option value="EVENT">EVENT</option>
@@ -99,8 +101,8 @@
             <label>Entity Type</label>
         </div>
         <div class= "input-field col s3">
-            <select name="germanenttype" class="ent-type-selection-action" data-ent-type="german">
-              <option value="" disabled selected>German ent_type</option>
+            <select id="corpus-analysis-concordance-german-ent-type-selection" name="germanenttype" class="ent-type-selection-action" data-ent-type="german">
+              <option value="default" disabled selected>German ent_type</option>
               <option value="LOC">LOC</option>
               <option value="MISC">MISC</option>
               <option value="ORG">ORG</option>
-- 
GitLab