diff --git a/docs/API-Reference/editor/CodeHintManager.md b/docs/API-Reference/editor/CodeHintManager.md index 986b86748c..653b60d6a4 100644 --- a/docs/API-Reference/editor/CodeHintManager.md +++ b/docs/API-Reference/editor/CodeHintManager.md @@ -227,6 +227,8 @@ insertHintOnTab Preference. * [.registerHintProvider(provider, languageIds, priority)](#module_CodeHintManager..registerHintProvider) * [.hasValidExclusion(exclusion, textAfterCursor)](#module_CodeHintManager..hasValidExclusion) ⇒ boolean * [.isOpen()](#module_CodeHintManager..isOpen) ⇒ boolean + * [.showHintsAtTop(handler)](#module_CodeHintManager..showHintsAtTop) + * [.clearHintsAtTop()](#module_CodeHintManager..clearHintsAtTop) @@ -263,3 +265,21 @@ Test if a hint popup is open. **Kind**: inner method of [CodeHintManager](#module_CodeHintManager) **Returns**: boolean - - true if the hints are open, false otherwise. + + +### CodeHintManager.showHintsAtTop(handler) +Register a handler to show hints at the top of the hint list. +This API allows extensions to add their own hints at the top of the standard hint list. + +**Kind**: inner method of [CodeHintManager](#module_CodeHintManager) + +| Param | Type | Description | +| --- | --- | --- | +| handler | Object | A hint provider object with standard methods: - hasHints: function(editor, implicitChar) - returns true if hints are available - getHints: function(editor, implicitChar) - returns hint response object with hints array - insertHint: function(hint) - handles hint insertion, returns true if handled | + + + +### CodeHintManager.clearHintsAtTop() +Unregister the hints at top handler. + +**Kind**: inner method of [CodeHintManager](#module_CodeHintManager) diff --git a/src/extensionsIntegrated/CustomSnippets/UIHelper.js b/src/extensionsIntegrated/CustomSnippets/UIHelper.js index 51a14308d4..1acf5c7bd9 100644 --- a/src/extensionsIntegrated/CustomSnippets/UIHelper.js +++ b/src/extensionsIntegrated/CustomSnippets/UIHelper.js @@ -20,7 +20,9 @@ /* eslint-disable no-invalid-this */ define(function (require, exports, module) { + const StringUtils = require("utils/StringUtils"); const Global = require("./global"); + const Strings = require("strings"); /** * this is a generic function to show error messages for input fields @@ -187,7 +189,7 @@ define(function (require, exports, module) { const wrapperId = isEditForm ? "edit-abbr-box-wrapper" : "abbr-box-wrapper"; const errorId = isEditForm ? "edit-abbreviation-duplicate-error" : "abbreviation-duplicate-error"; - showError(inputId, wrapperId, `A snippet with abbreviation "${abbreviation}" already exists.`, errorId); + showError(inputId, wrapperId, StringUtils.format(Strings.CUSTOM_SNIPPETS_DUPLICATE_ERROR, abbreviation), errorId); } /** diff --git a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js index c970879b6f..9d4d1ee5f5 100644 --- a/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js +++ b/src/extensionsIntegrated/CustomSnippets/codeHintIntegration.js @@ -21,6 +21,7 @@ define(function (require, exports, module) { const CodeHintManager = require("editor/CodeHintManager"); const EditorManager = require("editor/EditorManager"); + const Metrics = require("utils/Metrics"); const Global = require("./global"); const Driver = require("./driver"); @@ -118,6 +119,10 @@ define(function (require, exports, module) { const editor = EditorManager.getFocusedEditor(); if (editor) { + // to track the usage metrics + const fileCategory = Helper.categorizeFileExtensionForMetrics(matchedSnippet.fileExtension); + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "snipt", `use.${fileCategory}`); + // replace the typed abbreviation with the template text using cursor manager const wordInfo = Driver.getWordBeforeCursor(); const start = { line: wordInfo.line, ch: wordInfo.ch + 1 }; diff --git a/src/extensionsIntegrated/CustomSnippets/driver.js b/src/extensionsIntegrated/CustomSnippets/driver.js index bd3871558f..9cec45b893 100644 --- a/src/extensionsIntegrated/CustomSnippets/driver.js +++ b/src/extensionsIntegrated/CustomSnippets/driver.js @@ -18,8 +18,10 @@ * */ +/* global logger */ define(function (require, exports, module) { const EditorManager = require("editor/EditorManager"); + const Metrics = require("utils/Metrics"); const Global = require("./global"); const Helper = require("./helper"); @@ -43,9 +45,19 @@ define(function (require, exports, module) { if (shouldAddSnippetToList(snippetData)) { Global.SnippetHintsList.push(snippetData); + Helper.rebuildOptimizedStructures(); Helper.clearAllInputFields(); Helper.toggleSaveButtonDisability(); - SnippetsState.saveSnippetsToState(); + + // snippet creating metrics + const fileCategory = Helper.categorizeFileExtensionForMetrics(snippetData.fileExtension); + Metrics.countEvent(Metrics.EVENT_TYPE.EDITOR, "snipt", `add.${fileCategory}`); + + // save to file storage + SnippetsState.saveSnippetsToState() + .catch(function (error) { + logger.reportError(error, "Custom Snippets: failed to save new snippet to file storage"); + }); // we need to move back to snippets list view after a snippet is saved UIHelper.showSnippetListMenu(); @@ -87,7 +99,13 @@ define(function (require, exports, module) { // update the snippet in the list if (snippetIndex !== -1) { Global.SnippetHintsList[snippetIndex] = editedData; - SnippetsState.saveSnippetsToState(); + Helper.rebuildOptimizedStructures(); + + // save to file storage + SnippetsState.saveSnippetsToState() + .catch(function (error) { + logger.reportError(error, "Custom Snippets: failed to save edited snippet to file storage"); + }); // clear the stored data $editView.removeData("originalSnippet"); @@ -100,19 +118,24 @@ define(function (require, exports, module) { } /** - * This function handles the reset button click for editing a snippet - * It restores the original snippet data in the edit form + * This function is responsible to handle the cancel button click in the edit-snippet panel + * this resets the format to the last saved values and then moves back to the snippets-list panel */ - function handleResetBtnClick() { + function handleCancelEditBtnClick() { const $editView = $("#custom-snippets-edit"); const originalSnippet = $editView.data("originalSnippet"); if (originalSnippet) { - // restore original data in the form + // restore original data in the form to reset any changes Helper.populateEditForm(originalSnippet); - // update save button state - Helper.toggleEditSaveButtonDisability(); } + + $editView.removeData("originalSnippet"); + $editView.removeData("snippetIndex"); + + // navigate back to snippets list + UIHelper.showSnippetListMenu(); + SnippetsList.showSnippetsList(); } /** @@ -164,10 +187,8 @@ define(function (require, exports, module) { }; } - - exports.getWordBeforeCursor = getWordBeforeCursor; exports.handleSaveBtnClick = handleSaveBtnClick; exports.handleEditSaveBtnClick = handleEditSaveBtnClick; - exports.handleResetBtnClick = handleResetBtnClick; + exports.handleCancelEditBtnClick = handleCancelEditBtnClick; }); diff --git a/src/extensionsIntegrated/CustomSnippets/helper.js b/src/extensionsIntegrated/CustomSnippets/helper.js index 3eebe8bdaa..f68dfe6294 100644 --- a/src/extensionsIntegrated/CustomSnippets/helper.js +++ b/src/extensionsIntegrated/CustomSnippets/helper.js @@ -22,6 +22,7 @@ define(function (require, exports, module) { const StringMatch = require("utils/StringMatch"); const Global = require("./global"); const UIHelper = require("./UIHelper"); + const Strings = require("strings"); // list of all the navigation and function keys that are allowed inside the input fields const ALLOWED_NAVIGATION_KEYS = [ @@ -52,6 +53,77 @@ define(function (require, exports, module) { "F12" ]; + // Optimized data structures for fast snippet lookups + let snippetsByLanguage = new Map(); + let snippetsByAbbreviation = new Map(); + let allSnippetsOptimized = []; + + /** + * Preprocesses a snippet to add optimized lookup properties + * @param {Object} snippet - The original snippet object + * @returns {Object} - The snippet with added optimization properties + */ + function preprocessSnippet(snippet) { + const optimizedSnippet = { ...snippet }; + + // pre-compute lowercase abbreviation for faster matching + optimizedSnippet.abbreviationLower = snippet.abbreviation.toLowerCase(); + + // parse and create a Set of supported extensions for O(1) lookup + if (snippet.fileExtension.toLowerCase() === "all") { + optimizedSnippet.supportedLangSet = new Set(["all"]); + optimizedSnippet.supportsAllLanguages = true; + } else { + const extensions = snippet.fileExtension + .toLowerCase() + .split(",") + .map(ext => ext.trim()) + .filter(ext => ext); + optimizedSnippet.supportedLangSet = new Set(extensions); + optimizedSnippet.supportsAllLanguages = false; + } + + return optimizedSnippet; + } + + /** + * Rebuilds optimized data structures from the current snippet list + * we call this function whenever snippets are loaded, added, modified, or deleted + * i.e. whenever the snippetList is updated + */ + function rebuildOptimizedStructures() { + // clear existing structures + snippetsByLanguage.clear(); + snippetsByAbbreviation.clear(); + allSnippetsOptimized.length = 0; + + // Process each snippet + Global.SnippetHintsList.forEach(snippet => { + const optimizedSnippet = preprocessSnippet(snippet); + allSnippetsOptimized.push(optimizedSnippet); + + // Index by abbreviation (lowercase) for exact matches + snippetsByAbbreviation.set(optimizedSnippet.abbreviationLower, optimizedSnippet); + + // Index by supported languages/extensions + if (optimizedSnippet.supportsAllLanguages) { + // Add to a special "all" key for universal snippets + if (!snippetsByLanguage.has("all")) { + snippetsByLanguage.set("all", new Set()); + } + snippetsByLanguage.get("all").add(optimizedSnippet); + } else { + // Add to each supported extension + optimizedSnippet.supportedLangSet.forEach(ext => { + if (!snippetsByLanguage.has(ext)) { + snippetsByLanguage.set(ext, new Set()); + } + snippetsByLanguage.get(ext).add(optimizedSnippet); + }); + } + }); + } + /** * map the language IDs to their file extensions for snippet matching * this is needed because we expect the user to enter file extensions and not the file type inside the input field @@ -95,6 +167,67 @@ define(function (require, exports, module) { return languageMap[languageId] || languageId; } + /** + * This function is to make sure file extensions are properly formatted with leading dots + * because user may provide values in not very consistent manner, we need to handle all those cases + * For ex: what we expect: `.js, .html, .css` + * what user may provide: `js, html, css` or: `js html css` etc + * + * This function processes file extensions in various formats and ensures they: + * - Have a leading dot (if not empty or "all") + * - Are properly separated with commas and spaces + * - Don't contain empty or standalone dots + * - No consecutive commas + * + * @param {string} extension - The file extension(s) to process + * @returns {string} - The properly formatted file extension(s) + */ + function processFileExtensionInput(extension) { + if (!extension || extension === "all") { + return extension; + } + + // Step 1: normalize the input by converting spaces to commas if no commas exist + if (extension.includes(" ")) { + extension = extension.replace(/\s+/g, ","); + } + + let result = ""; + + // Step 2: process comma-separated extensions FIRST (before dot-separated) + // this prevents issues with inputs like ".js,.html,." or ".js,,.html" + if (extension.includes(",")) { + result = extension + .split(",") + .map((ext) => { + ext = ext.trim(); + // skip all the standalone dots or empty entries + if (ext === "." || ext === "") { + return ""; + } + // Add leading dot if missing + return ext.startsWith(".") ? ext : "." + ext; + }) + .filter((ext) => ext !== "") // Remove empty entries + .join(", "); + } else { + // Step 3: Handle single extension + if (extension === ".") { + result = ""; // remove standalone dot + } else { + // Add leading dot if missing + result = extension.startsWith(".") ? extension : "." + extension; + } + } + + // this is just the final safeguard to remove any consecutive commas and clean up spacing + result = result.replace(/,\s*,+/g, ",").replace(/,\s*$/, "").replace(/^\s*,/, "").trim(); + // remove trailing dots (like .css. -> .css) + result = result.endsWith('.') ? result.slice(0, -1) : result; + + return result; + } + /** * This function is responsible to get the snippet data from all the required input fields * it is called when the save button is clicked @@ -108,11 +241,14 @@ define(function (require, exports, module) { const templateText = $("#template-text-box").val().trim(); const fileExtension = $("#file-extn-box").val().trim(); + // process the file extension so that we can get the value in the required format + const processedFileExtension = processFileExtensionInput(fileExtension); + return { abbreviation: abbreviation, description: description || "", // allow empty description templateText: templateText, - fileExtension: fileExtension || "all" // default to "all" if empty + fileExtension: processedFileExtension || "all" // default to "all" if empty }; } @@ -128,7 +264,7 @@ define(function (require, exports, module) { const $abbrInput = $("#abbr-box"); const $templateInput = $("#template-text-box"); - const $saveBtn = $("#save-custom-snippet-btn button"); + const $saveBtn = $("#save-custom-snippet-btn"); // make sure that the required fields has some value const hasAbbr = $abbrInput.val().trim().length > 0; @@ -144,15 +280,9 @@ define(function (require, exports, module) { * @returns {string|null} - The language ID or null if not available */ function getCurrentLanguageContext(editor) { - // first try to get the language at cursor pos - // if it for some reason fails, then just go for the file extension - try { - const language = editor.getLanguageForPosition(); - const languageId = language ? language.getId() : null; - return languageId; - } catch (e) { - return getCurrentFileExtension(editor); - } + const language = editor.getLanguageForPosition(); + const languageId = language ? language.getId() : null; + return languageId; } /** @@ -172,32 +302,40 @@ define(function (require, exports, module) { * Checks if a snippet is supported in the given language context * Falls back to file extension matching if language mapping isn't available * - * @param {Object} snippet - The snippet object + * @param {Object} snippet - The snippet object (optimized or regular) * @param {string|null} languageContext - The current language context * @param {Editor} editor - The editor instance for fallback * @returns {boolean} - True if the snippet is supported */ function isSnippetSupportedInLanguageContext(snippet, languageContext, editor) { - if (snippet.fileExtension.toLowerCase() === "all") { + // Check for "all" languages support (both optimized and non-optimized) + if ( + snippet.supportsAllLanguages === true || + (snippet.fileExtension && snippet.fileExtension.toLowerCase() === "all") + ) { return true; } + // Try language context matching if available if (languageContext) { const effectiveExtension = mapLanguageToExtension(languageContext); // if we have a proper mapping (starts with .), use language context matching if (effectiveExtension.startsWith(".")) { + // Use optimized path if available + if (snippet.supportedLangSet) { + return snippet.supportedLangSet.has(effectiveExtension); + } + // Fallback for non-optimized snippets const supportedExtensions = snippet.fileExtension .toLowerCase() .split(",") .map((ext) => ext.trim()); - return supportedExtensions.some((ext) => ext === effectiveExtension); } } - // this is just a fallback if language context matching failed - // file extension matching + // final fallback for file extension matching if language context matching failed if (editor) { const fileExtension = getCurrentFileExtension(editor); return isSnippetSupportedInFile(snippet, fileExtension); @@ -238,12 +376,12 @@ define(function (require, exports, module) { const queryLower = query.toLowerCase(); const languageContext = getCurrentLanguageContext(editor); - return Global.SnippetHintsList.some((snippet) => { - if (snippet.abbreviation.toLowerCase() === queryLower) { - return isSnippetSupportedInLanguageContext(snippet, languageContext, editor); - } - return false; - }); + const snippet = snippetsByAbbreviation.get(queryLower); + if (snippet) { + return isSnippetSupportedInLanguageContext(snippet, languageContext, editor); + } + + return false; } /** @@ -256,21 +394,41 @@ define(function (require, exports, module) { const queryLower = query.toLowerCase(); const languageContext = getCurrentLanguageContext(editor); - const matchingSnippets = Global.SnippetHintsList.filter((snippet) => { - if (snippet.abbreviation.toLowerCase().startsWith(queryLower)) { - return isSnippetSupportedInLanguageContext(snippet, languageContext, editor); + // Get the candidate snippets for the current language/extension + let candidateSnippets = new Set(); + + // Add universal snippets (support "all" languages) + const universalSnippets = snippetsByLanguage.get("all"); + if (universalSnippets) { + universalSnippets.forEach(snippet => candidateSnippets.add(snippet)); + } + + // Add language-specific snippets + if (languageContext) { + const effectiveExtension = mapLanguageToExtension(languageContext); + if (effectiveExtension.startsWith(".")) { + const languageSnippets = snippetsByLanguage.get(effectiveExtension); + if (languageSnippets) { + languageSnippets.forEach(snippet => candidateSnippets.add(snippet)); + } } - return false; + } + + // Fallback: if we can't determine language, check all snippets + if (candidateSnippets.size === 0) { + candidateSnippets = new Set(allSnippetsOptimized); + } + + // Filter candidates by prefix match using pre-computed lowercase abbreviations + const matchingSnippets = Array.from(candidateSnippets).filter((snippet) => { + return snippet.abbreviationLower.startsWith(queryLower); }); // sort snippets so that the exact matches will appear over the partial matches return matchingSnippets.sort((a, b) => { - const aLower = a.abbreviation.toLowerCase(); - const bLower = b.abbreviation.toLowerCase(); - // check if either is an exact match - const aExact = aLower === queryLower; - const bExact = bLower === queryLower; + const aExact = a.abbreviationLower === queryLower; + const bExact = b.abbreviationLower === queryLower; // because exact matches appear first if (aExact && !bExact) { @@ -280,7 +438,7 @@ define(function (require, exports, module) { return 1; } - return aLower.localeCompare(bLower); + return a.abbreviationLower.localeCompare(b.abbreviationLower); }); } @@ -325,7 +483,7 @@ define(function (require, exports, module) { } // the codehints related style is written in brackets_patterns_override.less file - let $icon = $(`Snippet`); + let $icon = $(`${Strings.CUSTOM_SNIPPETS_HINT_LABEL}`); $hint.append($icon); if (description && description.trim() !== "") { @@ -374,11 +532,14 @@ define(function (require, exports, module) { const templateText = $("#edit-template-text-box").val().trim(); const fileExtension = $("#edit-file-extn-box").val().trim(); + // process the file extension so that we can get the value in the required format + const processedFileExtension = processFileExtensionInput(fileExtension); + return { abbreviation: abbreviation, description: description || "", // allow empty description templateText: templateText, - fileExtension: fileExtension || "all" // default to "all" if empty + fileExtension: processedFileExtension || "all" // default to "all" if empty }; } @@ -553,7 +714,7 @@ define(function (require, exports, module) { const wrapperId = isEditForm ? "edit-abbr-box-wrapper" : "abbr-box-wrapper"; const errorId = isEditForm ? "edit-abbreviation-space-error" : "abbreviation-space-error"; - UIHelper.showError(inputId, wrapperId, "Space is not accepted as a valid abbreviation character.", errorId); + UIHelper.showError(inputId, wrapperId, Strings.CUSTOM_SNIPPETS_SPACE_ERROR, errorId); return; } @@ -571,7 +732,7 @@ define(function (require, exports, module) { const wrapperId = isEditForm ? "edit-abbr-box-wrapper" : "abbr-box-wrapper"; const errorId = isEditForm ? "edit-abbreviation-length-error" : "abbreviation-length-error"; - UIHelper.showError(inputId, wrapperId, "Abbreviation cannot be more than 30 characters.", errorId); + UIHelper.showError(inputId, wrapperId, Strings.CUSTOM_SNIPPETS_ABBR_LENGTH_ERROR, errorId); } } @@ -600,7 +761,7 @@ define(function (require, exports, module) { const wrapperId = isEditForm ? "edit-desc-box-wrapper" : "desc-box-wrapper"; const errorId = isEditForm ? "edit-description-length-error" : "description-length-error"; - UIHelper.showError(inputId, wrapperId, "Description cannot be more than 80 characters.", errorId); + UIHelper.showError(inputId, wrapperId, Strings.CUSTOM_SNIPPETS_DESC_LENGTH_ERROR, errorId); } } @@ -663,15 +824,10 @@ define(function (require, exports, module) { // Prioritize length error over space error if both occurred if (wasTruncated) { const errorId = isEditForm ? "edit-abbreviation-paste-length-error" : "abbreviation-paste-length-error"; - UIHelper.showError(inputId, wrapperId, "Abbreviation cannot be more than 30 characters.", errorId); + UIHelper.showError(inputId, wrapperId, Strings.CUSTOM_SNIPPETS_ABBR_LENGTH_ERROR, errorId); } else if (hadSpaces) { const errorId = isEditForm ? "edit-abbreviation-paste-space-error" : "abbreviation-paste-space-error"; - UIHelper.showError( - inputId, - wrapperId, - "Space is not accepted as a valid abbreviation character.", - errorId - ); + UIHelper.showError(inputId, wrapperId, Strings.CUSTOM_SNIPPETS_SPACE_ERROR, errorId); } } @@ -739,7 +895,7 @@ define(function (require, exports, module) { const wrapperId = isEditForm ? "edit-desc-box-wrapper" : "desc-box-wrapper"; const errorId = isEditForm ? "edit-description-paste-length-error" : "description-paste-length-error"; - UIHelper.showError(inputId, wrapperId, "Description cannot be more than 80 characters.", errorId); + UIHelper.showError(inputId, wrapperId, Strings.CUSTOM_SNIPPETS_DESC_LENGTH_ERROR, errorId); } // Determine which save button to toggle based on input field @@ -750,6 +906,20 @@ define(function (require, exports, module) { } } + /** + * Categorize file extension for metrics tracking + * @param {string} fileExtension - The file extension from snippet + * @returns {string} - "all" if snippet is enabled for all files, otherwise "file" + */ + function categorizeFileExtensionForMetrics(fileExtension) { + if (!fileExtension || fileExtension === "all") { + return "all"; + } + + // if not enabled for "all", we just return "file" + return "file"; + } + exports.toggleSaveButtonDisability = toggleSaveButtonDisability; exports.createHintItem = createHintItem; exports.clearAllInputFields = clearAllInputFields; @@ -757,6 +927,7 @@ define(function (require, exports, module) { exports.getCurrentLanguageContext = getCurrentLanguageContext; exports.getCurrentFileExtension = getCurrentFileExtension; exports.mapLanguageToExtension = mapLanguageToExtension; + exports.rebuildOptimizedStructures = rebuildOptimizedStructures; exports.isSnippetSupportedInLanguageContext = isSnippetSupportedInLanguageContext; exports.isSnippetSupportedInFile = isSnippetSupportedInFile; exports.hasExactMatchingSnippet = hasExactMatchingSnippet; @@ -769,6 +940,7 @@ define(function (require, exports, module) { exports.populateEditForm = populateEditForm; exports.getEditSnippetData = getEditSnippetData; exports.toggleEditSaveButtonDisability = toggleEditSaveButtonDisability; + exports.categorizeFileExtensionForMetrics = categorizeFileExtensionForMetrics; exports.clearEditInputFields = clearEditInputFields; exports.handleTextareaTabKey = handleTextareaTabKey; exports.validateAbbrInput = validateAbbrInput; diff --git a/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html b/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html index 67d85ead76..f1b5e3c0e2 100644 --- a/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html +++ b/src/extensionsIntegrated/CustomSnippets/htmlContent/snippets-panel.html @@ -1,25 +1,24 @@
- Custom Snippets + {{Strings.CUSTOM_SNIPPETS_PANEL_TITLE}}
-
- -
-
- +
+ ×
@@ -27,17 +26,17 @@
-
No custom snippets added yet!
+
{{Strings.CUSTOM_SNIPPETS_NO_SNIPPETS_MESSAGE}}
- +
-
Abbreviation
-
Template Text
-
Description
-
File Extension
+
{{Strings.CUSTOM_SNIPPETS_HEADER_ABBREVIATION}}
+
{{Strings.CUSTOM_SNIPPETS_HEADER_TEMPLATE}}
+
{{Strings.CUSTOM_SNIPPETS_HEADER_DESCRIPTION}}
+
{{Strings.CUSTOM_SNIPPETS_HEADER_FILE_EXTENSION}}
@@ -70,9 +69,9 @@
@@ -81,38 +80,39 @@
- +
- +
- +
-
- +
+ +
@@ -121,9 +121,9 @@
@@ -132,39 +132,39 @@
- +
- +
- +