File indexing completed on 2024-05-12 15:21:21
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 }