File indexing completed on 2024-05-19 03:43:52

0001 /* GCompris - grammar_analysis.js
0002  *
0003  * Copyright (C) 2022-2023 Bruno ANSELME <be.root@free.fr>
0004  *
0005  * Authors:
0006  *   Bruno ANSELME <be.root@free.fr> (Qt Quick native)
0007  *
0008  *   SPDX-License-Identifier: GPL-3.0-or-later
0009  */
0010 .pragma library
0011 .import QtQuick 2.12 as Quick
0012 .import GCompris 1.0 as GCompris //for ApplicationInfo
0013 .import "qrc:/gcompris/src/core/core.js" as Core
0014 
0015 // Punctuation includes symbols for all writing systems
0016 const punctuation = "\\,|\\.|:|!|¡|;|\\?|¿|\\\"|«|»|“|”|„|؟|،|。|,|:|?|!|>|<|&|#|=|\\*"
0017 
0018 const dataUrl = "qrc:/gcompris/src/activities/grammar_analysis/resource/"
0019 const svgUrl = dataUrl + "svg/"
0020 const defaultLevelsFile = "en";
0021 
0022 var dataSetUrl = ""
0023 var syntaxList = []
0024 
0025 var numberOfLevel
0026 var grammarMode
0027 var translationMode
0028 var items
0029 var datas = null                // Contains parsed grammar-lang.json file
0030 var goalArray = []
0031 var boxCount = 0                // Total boxes for current sentence (a box is an expected grammatical class)
0032 var animRunning = false
0033 
0034 var tutorialInstructions = {
0035     "_analysis" : [
0036         {
0037             "instruction": "<b>" + qsTr("Grammatical analysis") + "</b><br><br>" +
0038                            qsTr("Learn to identify grammatical classes.") + ("<br>") +
0039                            qsTr("Find all the words corresponding to the requested grammatical classes.") + ("<br>") +
0040                            qsTr("Select a grammatical class from the list, then select the box under a word and assign it the class.") + ("<br>") +
0041                            qsTr("Leave the box blank if no class matches."),
0042             "instructionQml": ""
0043         }
0044     ],
0045     "_classes" : [
0046         {
0047             "instruction": "<b>" + qsTr("Grammatical classes") + "</b><br><br>" +
0048                            qsTr("Learn to identify grammatical classes.") + "<br>" +
0049                            qsTr("Find all the words corresponding to the requested grammatical class.") + ("<br>") +
0050                            qsTr("Select the grammatical class from the list, then select the box under a word and assign it the class.") + ("<br>") +
0051                            qsTr("Leave the box blank if the class doesn't match."),
0052             "instructionQml": ""
0053         }
0054     ]
0055 }
0056 
0057 // Try to read a grammar file
0058 function checkFileName(fileName) {
0059     datas = items.jsonParser.parseFromUrl(fileName)
0060     if (datas === null) {
0061         console.warn("Grammar_analysis: Invalid data file " + fileName);
0062         return ""
0063     }
0064     return fileName         // returns a fileName if datas are valid
0065 }
0066 
0067 // Build a file name with locale, try to read it
0068 function checkFile(locale) {
0069     return checkFileName(GCompris.ApplicationInfo.getLocaleFilePath(dataSetUrl + "grammar" + grammarMode + "-" + locale + ".json"))
0070 }
0071 
0072 function loadDatas() {
0073     datas = null
0074     // Reset models, objective and goal
0075     items.syntaxModel.clear()
0076     items.datasetModel.clear()
0077     items.goalModel.clear()
0078     items.answerModel.clear()
0079     items.objective.text = ""
0080     goalArray = []
0081     //--- Reset debug informations
0082     items.goalStr = ""
0083     items.phrase.text = ""
0084     items.response.text = ""
0085     items.inspector.message = ""
0086     //--- End debug informations
0087 
0088     var locale = GCompris.ApplicationInfo.getVoicesLocale(items.locale);
0089     var fileName = "file://" + GCompris.ApplicationSettings.userDataPath + "/grammar" + grammarMode + "-xx.json"    // Translator file
0090     if (items.file.exists(fileName)) {  // Load translator file if exists. Even with errors.
0091         datas = items.jsonParser.parseFromUrl(fileName)
0092     } else {                            // Check for a valid file name, datas are loaded in checkFile
0093         fileName = checkFile(locale);
0094         if (fileName === "") fileName = checkFile(defaultLevelsFile)    // Try default file
0095     }
0096 
0097     items.locale = locale
0098     items.translationFile = fileName.split('/').reverse()[0]            // Extract basename for debug infos
0099     var dataErrors = []
0100     if (datas !== null) {                                               // Check datas main keys. Stop if something is missing
0101         if (!datas.hasOwnProperty('levels')) dataErrors.push('levels missing')
0102         if (!datas.hasOwnProperty('dataset')) dataErrors.push('dataset missing')
0103         if (!datas.hasOwnProperty("syntax")) dataErrors.push('syntax missing')
0104         if (dataErrors.length) {
0105             items.errors.text = 'Error in file: ' +fileName + '\n' + dataErrors.join("\n")
0106             datas = null
0107         }
0108     }
0109     // Values loaded once and stored to avoid multiple calls
0110     datas.needSpaces = Core.localeNeedSpaces(items.locale)
0111     datas.rightToLeft = Core.isLeftToRightLocale(items.locale)
0112 }
0113 
0114 function start(items_, grammarMode_, translationMode_) {
0115     items = items_
0116     grammarMode = grammarMode_
0117     translationMode = translationMode_
0118     dataSetUrl = "qrc:/gcompris/src/activities/grammar" + grammarMode + "/resource/"
0119     numberOfLevel = 0
0120     items.currentExercise = 0
0121     loadDatas();
0122     if (datas === null) return
0123 
0124     checkLevels()
0125     numberOfLevel = datas["levels"].length
0126     items.currentLevel = Core.getInitialLevel(numberOfLevel)
0127     items.wordsFlow.layoutDirection = (datas.rightToLeft) ? Qt.LeftToRight : Qt.RightToLeft
0128     initSyntax()
0129     initExercises()
0130     initLevel()
0131 }
0132 
0133 function stop() {}
0134 
0135 // Check each level for valid exercises. Remove empty levels
0136 function checkLevels() {
0137     var level = 0
0138     while (level < datas["levels"].length) {
0139         var oneGood = false
0140         var exercises = datas.dataset[datas["levels"][level].exercise];
0141         if(!exercises) {
0142             console.warn("Grammar_analysis: dataset " + datas["levels"][level].exercise + " is not defined in the dataset section, we ignore it");
0143             level++;
0144             continue;
0145         }
0146         for (var i = 0; i < exercises.length ; i++) {
0147             var parsed = analyzeExercise(level, exercises[i])
0148             if (checkExercise(parsed, translationMode)) {
0149                 oneGood = true
0150             }
0151         }
0152         if (oneGood) {      // keep this level
0153             level++
0154         } else {            // remove this level
0155             datas["levels"].splice(level, 1)
0156         }
0157     }
0158 }
0159 
0160 // Check if exercise fits goal (translators should see something)
0161 function checkExercise(parsed, force = false) {
0162     if (force) return true          // Translators should see all datasets with an error message.
0163     var usedClasses = []            // contains single and merged classes
0164     for (var k = 0; k < parsed.classes.length; k++) {
0165         usedClasses = usedClasses.concat(parsed.classes[k].split(/\+/))
0166     }
0167     var j
0168     var isValid = false
0169     for (j = 0; j < goalArray.length; j++) {    // At least one goal's class must be present
0170         isValid |= (usedClasses.indexOf(goalArray[j]) !== -1)
0171     }
0172     parsed.isValid = isValid
0173     return isValid
0174 }
0175 
0176 // Load dataset model for current level
0177 // Keep only valid exercises except for translators
0178 function initExercises() {
0179     if (datas === null) return
0180     items.datasetModel.clear()
0181     var exercises = datas.dataset[datas["levels"][items.currentLevel].exercise];
0182     for (var i = 0; i < exercises.length ; i++) {
0183         var parsed = analyzeExercise(items.currentLevel, exercises[i])
0184         if (checkExercise(parsed, translationMode)) {
0185             items.datasetModel.append({ "exercise" : exercises[i] })
0186         }
0187     }
0188     if (items.datasetModel.count === 0) {
0189         nextLevel()
0190     }
0191     if (!translationMode)
0192         items.datasetModel.shuffleModel()
0193 }
0194 
0195 // Load writing system's syntax
0196 function initSyntax() {
0197     items.syntaxModel.clear()
0198     for (var i = 0; i < datas.syntax.length ; i++) {
0199         items.syntaxModel.append(
0200                     {
0201                         "code" : datas.syntax[i].code,
0202                         "wordClass" : datas.syntax[i].wordClass,
0203                         "image" : datas.syntax[i].image
0204                     })
0205     }
0206     syntaxList = toArrayKeys(items.syntaxModel, "code")
0207 }
0208 
0209 function initLevel() {
0210     items.errorRectangle.resetState()
0211     items.selectedClass = 0
0212     items.selectedBox = 0
0213     items.keysOnTokens = true
0214     if (datas === null) return
0215     buildAnswer()
0216     items.buttonsBlocked = false
0217 }
0218 
0219 function nextLevel() {
0220     items.score.stopWinAnimation()
0221     items.currentExercise = 0
0222     items.currentLevel = Core.getNextLevel(items.currentLevel, numberOfLevel);
0223     initExercises()
0224     initLevel();
0225 }
0226 
0227 function previousLevel() {
0228     items.score.stopWinAnimation()
0229     items.currentLevel = Core.getPreviousLevel(items.currentLevel, numberOfLevel);
0230     if (datas === null) return
0231     items.currentExercise = 0
0232     initExercises()
0233     initLevel();
0234 }
0235 
0236 function nextSubLevel() {
0237     if (items.currentExercise >= items.datasetModel.count) {
0238         items.bonus.good("sun")
0239     } else {
0240         initLevel();
0241     }
0242 }
0243 
0244 function nextSubLevelShortcut() {
0245     if( ++items.currentExercise >= items.datasetModel.count) {
0246         items.currentExercise = 0;
0247         nextLevel();
0248     } else {
0249         initLevel();
0250     }
0251 }
0252 
0253 function previousSubLevelShortcut() {
0254     if( --items.currentExercise < 0) {
0255         previousLevel();
0256     } else {
0257         initLevel();
0258     }
0259 }
0260 
0261 function checkResult() {
0262     items.buttonsBlocked = true
0263     var ok = true
0264     for (var i = 0; i < items.rowAnswer.count; i++) {
0265         var wordCard = items.rowAnswer.itemAt(i)
0266         if (wordCard.expected !== "")
0267             ok &= (wordCard.expected === wordCard.proposition)
0268     }
0269     if (ok){
0270         items.audioEffects.play("qrc:/gcompris/src/core/resource/sounds/completetask.wav")
0271         items.currentExercise += 1
0272         items.score.playWinAnimation()
0273     } else {
0274         items.audioEffects.play("qrc:/gcompris/src/core/resource/sounds/crash.wav")
0275         items.errorRectangle.startAnimation()
0276     }
0277 }
0278 
0279 // builds an array with the model's propertyName (indexOf will give an index in the model)
0280 function toArrayKeys(model, propertyName) {
0281     var arr = []
0282     for (var i = 0; i < model.count; i++)
0283         arr.push(model.get(i)[propertyName])
0284     return arr
0285 }
0286 
0287 function handleKeys(event) {
0288     if (animRunning || items.buttonsBlocked) return         // No key during animation
0289     if ((event.modifiers & Qt.AltModifier) && translationMode) {
0290         switch (event.key) {        // sublevel navigation in translation mode
0291         case Qt.Key_Left:
0292             previousSubLevelShortcut()
0293             break
0294         case Qt.Key_Right:
0295             nextSubLevelShortcut()
0296             break
0297         case Qt.Key_Return:         // Switch visibility of infoView and inspector with Ctrl+Alt+Enter
0298             if (event.modifiers & Qt.ControlModifier) {
0299                 items.debugActive = !items.debugActive
0300             }
0301             break
0302         }
0303     } else {
0304         switch (event.key) {
0305         case Qt.Key_Left:
0306             items.keyboardNavigation = true
0307             if (items.keysOnTokens)
0308                 items.selectedClass = (items.selectedClass + items.goalModel.count - 1) % items.goalModel.count
0309             else
0310                 items.selectedBox = (items.selectedBox + boxCount -1) % boxCount
0311             break
0312         case Qt.Key_Right:
0313             items.keyboardNavigation = true
0314             if (items.keysOnTokens)
0315                 items.selectedClass = (items.selectedClass + 1) % items.goalModel.count
0316             else
0317                 items.selectedBox = (items.selectedBox +1) % boxCount
0318             break
0319         case Qt.Key_Up:
0320         case Qt.Key_Down:
0321         case Qt.Key_Tab:
0322             items.keyboardNavigation = true
0323             items.keysOnTokens = !items.keysOnTokens
0324             break
0325         case Qt.Key_Backspace:
0326             items.keyboardNavigation = true
0327             items.selectedBox = (items.selectedBox + boxCount - 1) % boxCount
0328             break
0329         case Qt.Key_Enter:
0330         case Qt.Key_Return:
0331             if (items.okButton.visible)
0332                 checkResult()
0333             break
0334         case Qt.Key_Space:
0335             items.keyboardNavigation = true
0336             var pos = items.boxIndexes[items.selectedBox].split('-')
0337             items.wordsFlow.children[pos[0]].setProposal(pos[1])
0338             break
0339         default :
0340             break
0341         }
0342     }
0343 }
0344 
0345 // Build datas for answerModel
0346 function buildAnswer() {
0347     var parsed = analyzeExercise(items.currentLevel, items.datasetModel.get(items.currentExercise).exercise)
0348     checkExercise(parsed)       // Add isValid flag
0349     items.objective.text = datas["levels"][items.currentLevel].objective
0350     var idx = 0
0351     var word
0352     // Update answerModel
0353     items.answerModel.clear()
0354     if (parsed.errors === "") {
0355         // Check for space and/or punctuation before first word, extract and insert
0356         if (parsed.indexes[0] > 0) {
0357             word = parsed.cleaned.slice(0, parsed.indexes[0])           // Cut string before first word
0358             if (datas.needSpaces || (word !== ' '))                     // Test for writing systems with no spaces
0359                 items.answerModel.append(                               // Append space or/and punctuation
0360                             {  'code': '',
0361                                'svg': '',
0362                                'word': word,
0363                                'prop': ''
0364                             })
0365         }
0366         boxCount = 0
0367         items.boxIndexes = []
0368         for (var i = 0 ; i < parsed.classes.length ; i ++) {                   // Loop on classes (or words)
0369             var classList = parsed.classes[i].split(/\+/)               // Split merged classes
0370             var wordCodes = []
0371             var bCount = boxCount
0372             for (var j = 0; j < classList.length; j++) {                // Loop on merged classes
0373                 idx = syntaxList.indexOf(classList[j])                  // Find index for this class
0374                 if (idx !== -1) {                                       // Check if it's a know class
0375                     if (goalArray.includes(datas.syntax[idx].code)) {   // Check if it's an expected class
0376                         wordCodes.push(datas.syntax[idx].code)          // Push grammar class
0377                         items.boxIndexes.push((items.answerModel.count) + "-" + j)
0378                         boxCount++
0379                     } else {
0380                         wordCodes.push("_")                             // Box is shown but should be empty
0381                         items.boxIndexes.push((items.answerModel.count) + "-" + j)
0382                         boxCount++
0383                     }
0384                 }
0385             }
0386             items.answerModel.append({'code': wordCodes.join('+'), 'svg': '', 'word': parsed.words[i], 'prop' : '', 'startCount': bCount })
0387 
0388             // Check for space and/or punctuation between words, extract and insert
0389             var limit = (i < parsed.words.length - 1 ) ? parsed.indexes[i+1] : parsed.cleaned.length    // Expected word end
0390             if (parsed.indexes[i] + parsed.words[i].length < limit) {                                   // If the next word is further
0391                 word = parsed.cleaned.slice(parsed.indexes[i] + parsed.words[i].length, limit)          // Cut string between words
0392                 if (datas.needSpaces || (word !== ' '))                                                 // Test for writing systems with no spaces
0393                     items.answerModel.append(
0394                                 {   'code': '',
0395                                     'svg': '',
0396                                     'word': word,
0397                                     'prop': ''
0398                                 })        // Append space or/and punctuation
0399             }
0400         }
0401     }
0402     // Check goal's syntax
0403     var k
0404     for (k = 0; k < goalArray.length; k++) {
0405         if (!syntaxList.includes(goalArray[k]))
0406             parsed.errors += "Unknown goal code : " + goalArray[k] +"<br>"
0407     }
0408     // Update goalModel
0409     items.goalModel.clear()
0410     for (k = 0; k < syntaxList.length ; k++) {                          // Loop on syntax order
0411         if (goalArray.includes(syntaxList[k])) {                        // If class found in goal
0412             items.goalModel.append(
0413                         {   'code': datas.syntax[k].code,
0414                             'wordClass': datas.syntax[k].wordClass,
0415                             'image': svgUrl + datas.syntax[k].image
0416                         })
0417         }
0418     }
0419     items.goalModel.append(
0420                 {   'code': "eraser",
0421                     'wordClass': qsTr("Empty"),
0422                     'image': 'qrc:/gcompris/src/core/resource/cancel.svg'
0423                 })        // Add erase button
0424     // Display errors found
0425     items.errors.text = (parsed.errors === "") ? "" : "<b>Error(s) :</b><br>" + parsed.errors
0426     if (!parsed.isValid) {
0427         items.errors.text += "At least one goal class must be present"
0428     }
0429 
0430     //--- Update debugging informations
0431     items.phrase.text = items.datasetModel.get(items.currentExercise).exercise.sentence
0432     items.response.text = items.datasetModel.get(items.currentExercise).exercise.answer
0433     items.inspector.message = JSON.stringify(parsed, null, 4)
0434     //--- End debug informations
0435 }
0436 
0437 // Analyze an exercise. Store splitted parts and computed parts in an object
0438 function analyzeExercise(level, exercise) {
0439     var parsed = {}                                                             // Object container for parsed segments
0440     parsed.sentence = exercise.sentence.replace(/\s+/,' ').trim()               // Trim multiple spaces
0441     parsed.answer = exercise.answer.replace(/\s+/,' ').trim()                   // Trim multiple spaces
0442     parsed.cleaned = parsed.sentence.replace(/\(|\)/g,'')                       // Delete parentheses to get a cleaned string
0443     parsed.classes = parsed.answer.replace(/  +/g, ' ').split(/ /)              // Clear multiple spaces and extract classes
0444 
0445     var tempStr = parsed.sentence                                               // Work in a temporary string
0446     tempStr = tempStr.replace(/([\\'|’])/g,"$1 ")                               // Single quote as word delimiter
0447     var parentheses = tempStr.match(/\([^\(\)]+\)/g)                            // Extract parentheses blocks in an array
0448     var regex = new RegExp(punctuation, "g");                                   // Build regular expression with punctuation
0449     tempStr = tempStr.replace(regex,' ')                                        // Punctuation is replaced by spaces
0450     tempStr = tempStr.replace(/ +/g, ' ').trim()                                // Clear multiple spaces
0451     tempStr = tempStr.replace(/\([^\(\)]+\)/g,"\t")                             // Replace parentheses blocks with a tabulation char
0452     parsed.words = tempStr.split(/ /)                                           // Cleared string can be splitted now
0453     if (parentheses !== null) {
0454         var idx = 0
0455         for (var i=0; i<parsed.words.length ; i++) {
0456             if (parsed.words[i] === "\t") {                                     // Restore parentheses blocks when tabulation char found
0457                 parsed.words[i] = parentheses[idx++].replace(/\(|\)/g,'')       // Eliminate parentheses
0458             }
0459         }
0460     }
0461 
0462     // Calculate position for each word in the cleaned string
0463     parsed.indexes = Array()
0464     var strPosition = 0
0465     var numWord = 0
0466     while (numWord < parsed.words.length) {
0467         var strPos = parsed.cleaned.indexOf(parsed.words[numWord], strPosition) // Find position of current word
0468         parsed.indexes.push(strPos)                                             // Save position in indexes array
0469         strPosition = strPos + parsed.words[numWord].length                     // Move to next start position
0470         numWord++
0471     }
0472 
0473     // Test if no spaces required
0474     if (datas.needSpaces) {
0475         tempStr = tempStr.replace(/ */g, '')                                    // Clear all spaces
0476     }
0477 
0478     // Syntax check
0479     parsed.errors = ""
0480     if (parsed.words.length !== parsed.classes.length)                         // Check words and classes count
0481         parsed.errors += parsed.words.length + " classes expected, "+ parsed.classes.length + " found.<br>"
0482     for (numWord=0; numWord<Math.min(parsed.words.length, parsed.classes.length); numWord++) {
0483         var classList = parsed.classes[numWord].split(/\+/)                    // Split merged classes
0484         for (var j = 0; j < classList.length; j++) {                           // Loop on merged classes
0485             if (!syntaxList.includes(classList[j]))                            // Check if it's a known class
0486                 parsed.errors += "Unknown code : " + classList[j] +"<br>"
0487         }
0488     }
0489     goalArray = datas["levels"][level].goal.split(/ /)            // Create an array of expected classes
0490     if (grammarMode === '_classes') {
0491         goalArray = goalArray.slice(0,1)                          // Keep only first goal for _classes
0492     }
0493     items.goalStr = goalArray.join(' ')     // Debug display
0494     return parsed
0495 }